diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..5b83cf8 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,29 @@ +# Normalize line endings +* text=auto + +# PHP files +*.php text eol=lf diff=php + +# Config files +*.json text eol=lf +*.md text eol=lf +*.xml 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 +.phpunit.* export-ignore +codecov.yml export-ignore +phpunit.xml* export-ignore +tests/ export-ignore +coverage/ export-ignore +coverage.clover export-ignore +coverage.xml export-ignore +DEVELOPMENT.md export-ignore +phpunit.xml* export-ignore +tests/ export-ignore +vendor/ export-ignore \ No newline at end of file diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 2b074c3..2c55cf0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,11 +31,8 @@ jobs: allowed-to-fail: false - php: '8.4' allowed-to-fail: false - - # Future-ready: PHP 8.5 (alpha/dev) - when available - php: '8.5' - stability: 'dev' - allowed-to-fail: true + allowed-to-fail: false # Development stability tests - php: '8.4' @@ -78,11 +75,8 @@ jobs: composer config minimum-stability ${{ matrix.stability }} composer config prefer-stable true - - name: Remove composer.lock - run: rm -f composer.lock - - name: Install dependencies - run: composer update --prefer-dist --no-interaction --no-progress + run: composer install --prefer-dist --no-interaction --no-progress - name: Validate composer.json and composer.lock run: composer validate --strict @@ -93,9 +87,19 @@ jobs: - name: Run static analysis run: composer analyse - - name: Run tests + - name: Run tests (Unit Tests only) run: composer test + - name: Run integration tests (manual trigger) + if: github.event_name == 'workflow_dispatch' + env: + DISCOGS_CONSUMER_KEY: ${{ secrets.DISCOGS_CONSUMER_KEY }} + DISCOGS_CONSUMER_SECRET: ${{ secrets.DISCOGS_CONSUMER_SECRET }} + DISCOGS_PERSONAL_ACCESS_TOKEN: ${{ secrets.DISCOGS_PERSONAL_ACCESS_TOKEN }} + run: | + # Public tests run without credentials, auth tests skip if credentials missing + composer test-integration -- --testdox + code-quality: runs-on: ubuntu-latest name: Code Quality Checks @@ -159,8 +163,8 @@ jobs: - name: Install dependencies run: composer install --prefer-dist --no-interaction --no-progress - - name: Run tests with coverage - run: vendor/bin/phpunit --coverage-clover coverage.xml + - name: Run tests with coverage (Unit Tests only) + run: composer test-coverage - name: Upload coverage to Codecov uses: codecov/codecov-action@v4 diff --git a/.gitignore b/.gitignore index 2f12f36..c92bde5 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,8 @@ composer.lock # Coverage reports coverage/ +coverage.xml +*.clover # Environment files .env diff --git a/CHANGELOG.md b/CHANGELOG.md index 623a78b..5187de8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,13 +5,164 @@ 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). -## [Unreleased] +## [4.0.0](https://github.com/calliostro/php-discogs-api/releases/tag/v4.0.0) โ€“ 2025-12-01 + +### ๐Ÿš€ Complete Library Redesign โ€“ v4.0 is a Fresh Start + +**v4.0.0** represents a fundamental architectural overhaul. This is not an incremental update โ€“ it's a complete rewrite prioritizing developer experience, type safety, and minimal code footprint. + +### Breaking Changes from v3.x + +#### 1. Class Renaming for Consistency + +- `DiscogsApiClient` โ†’ `DiscogsClient` +- `ClientFactory` โ†’ `DiscogsClientFactory` + +#### 2. Method Naming Revolution + +**All 60 API methods renamed** following consistent `verb + noun` patterns: + +- `artistGet()` โ†’ `getArtist()` +- `artistReleases()` โ†’ `listArtistReleases()` +- `releaseGet()` โ†’ `getRelease()` +- `userEdit()` โ†’ `updateUser()` +- `collectionFolders()` โ†’ `listCollectionFolders()` +- `inventoryGet()` โ†’ `getUserInventory()` +- `listingCreate()` โ†’ `createMarketplaceListing()` +- `ordersGet()` โ†’ `getMarketplaceOrders()` + +#### 3. Clean Parameter API (No More Arrays) + +**Revolutionary method signatures** eliminate array parameters entirely: + +```php +// v3.x (OLD) +$artist = $discogs->artistGet(['id' => 5590213]); +$search = $discogs->search(['q' => 'Billie Eilish', 'type' => 'artist', 'per_page' => 50]); +$collection = $discogs->collectionItems(['username' => 'user', 'folder_id' => 0]); + +// v4.0 (NEW) - Clean parameters +$artist = $discogs->getArtist(5590213); +$search = $discogs->search(query: 'Billie Eilish', type: 'artist', perPage: 50); +$collection = $discogs->listCollectionItems(username: 'user', folderId: 0); +``` + +#### 4. Enhanced Authentication Architecture + +**Complete authentication rewrite** with proper security standards: + +- **Personal Access Token**: Now requires consumer credentials for proper Discogs Auth format +- **OAuth 1.0a**: RFC 5849 compliant with PLAINTEXT signatures +- **Method Renaming**: `createWithToken()` โ†’ `createWithPersonalAccessToken()` + +```php +// v3.x (OLD) +$discogs = ClientFactory::createWithToken('token'); + +// v4.0 (NEW) +$discogs = DiscogsClientFactory::createWithPersonalAccessToken('key', 'secret', 'token'); +``` + +### What's New in v4.0 + +#### Revolutionary Developer Experience + +- **Zero Array Parameters** โ€“ Direct method calls: `getArtist(123)` vs `getArtist(['id' => 123])` +- **Perfect IDE Autocomplete** โ€“ Full IntelliSense support with typed parameters +- **Type Safety** โ€“ Automatic parameter validation and conversion (DateTime, booleans, objects) +- **Self-Documenting Code** โ€“ Method names clearly indicate action and resource + +#### Ultra-Lightweight Architecture + +- **~750 Lines Total** โ€“ Minimal codebase covering all 60 Discogs API endpoints +- **2 Core Classes** โ€“ `DiscogsClient` and `DiscogsClientFactory` handle everything +- **Zero Bloat** โ€“ No unnecessary abstractions or complex inheritance hierarchies +- **Direct API Mapping** โ€“ Each method maps 1:1 to a Discogs endpoint + +#### Enterprise-Grade Security + +- **RFC 5849 OAuth 1.0a** โ€“ Industry-standard OAuth implementation +- **Secure Nonce Generation** โ€“ Cryptographically secure random values +- **ReDoS Protection** โ€“ Input validation prevents regular expression attacks +- **Proper Authentication Headers** โ€“ Discogs-compliant auth format + +#### Comprehensive Type Safety + +- **Strict Parameter Validation** โ€“ Only camelCase parameters from PHPDoc accepted +- **Automatic Type Conversion** โ€“ DateTime โ†’ ISO 8601, boolean โ†’ "1"/"0" for queries +- **Required Parameter Enforcement** โ€“ `null` values rejected for required parameters +- **Object Support** โ€“ Custom objects with `__toString()` method automatically converted + +### Migration Impact + +**This is a complete breaking change.** Every method call in your codebase will need updating: + +1. **Update class names**: `DiscogsApiClient` โ†’ `DiscogsClient`, `ClientFactory` โ†’ `DiscogsClientFactory` +2. **Update method names**: Use the complete mapping table in [UPGRADE.md](UPGRADE.md) +3. **Remove all arrays**: Convert array parameters to positional parameters +4. **Update authentication**: Personal tokens now require consumer credentials + +### Design Goals + +**v4.0 prioritizes long-term developer experience:** + +- **Cleaner Code**: Direct method calls without array parameters +- **Better IDE Support**: Full autocomplete and type checking +- **Consistent API**: All methods follow the same naming pattern +- **Type Safety**: Catch errors at development time, not runtime + +### Added Features + +- **Complete OAuth 1.0a Support** with `OAuthHelper` class for full authorization flows +- **Enhanced Error Handling** with clear exception messages for migration issues +- **Integration Test Suite** with comprehensive authentication level testing +- **CI/CD Integration** with automatic rate limiting and retry logic +- **Static Analysis** โ€“ PHPStan Level 8 compliance with zero errors +- **Performance Optimizations** โ€“ Config caching and reduced file I/O operations +- **Consistent Class Naming** โ€“ `DiscogsClient` and `DiscogsClientFactory` for better clarity + +### Migration Resources + +- **Complete Method Mapping**: See [UPGRADE.md](UPGRADE.md) for all 60 method name changes +- **Parameter Examples**: Detailed before/after code samples for common operations +- **Authentication Guide**: Step-by-step migration for all authentication types +- **Automated Scripts**: Bash/sed commands to help identify and replace common patterns + +--- + +## [3.1.0](https://github.com/calliostro/php-discogs-api/releases/tag/v3.1.0) โ€“ 2025-09-09 + +### Added + +- **OAuth 1.0a Helper Methods** โ€“ Complete OAuth flow support with a separate OAuthHelper class + - `getRequestToken()` โ€“ Get temporary request token for authorization flow + - `getAuthorizationUrl()` โ€“ Generate user authorization URL + - `getAccessToken()` โ€“ Exchange request token for permanent access token +- **Clean Authentication API** โ€“ Dedicated methods for different authentication types + - `createWithPersonalAccessToken()` โ€“ Clean 3-parameter method for Personal Access Tokens + - `createWithOAuth()` โ€“ Refined 4-parameter method for OAuth 1.0a tokens only +- **Enhanced OAuth Documentation** โ€“ Comprehensive OAuth workflow examples and security best practices +- **OAuth Unit Tests** โ€“ Full test coverage for new OAuth helper methods and authentication methods + +### Changed + +- **BREAKING**: ClientFactory methods now accept array|GuzzleClient parameters (following LastFm pattern) +- **Authentication API Redesign** โ€“ Cleaner separation between Personal Access Token and OAuth 1.0a authentication +- Updated all default User-Agent strings to version `3.1.0` +- Enhanced OAuth client creation with a proper PLAINTEXT signature method +- Documentation restructured for better usability + +### Fixed + +- OAuth request token method now uses a proper HTTP method (GET instead of POST) +- OAuth signature generation follows Discogs API requirements exactly +- PHPStan Level 8 compatibility with proper type annotations for OAuth responses ## [3.0.1](https://github.com/calliostro/php-discogs-api/releases/tag/v3.0.1) โ€“ 2025-09-09 ### Added -- Complete PHPDoc coverage for all 62 Discogs API endpoints +- Complete PHPDoc coverage for all 60 Discogs API endpoints - Missing @method annotations for 22 additional API methods - Full IDE autocomplete support for inventory, collection, and marketplace operations @@ -33,8 +184,8 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Ultra-lightweight 2-class architecture: `ClientFactory` and `DiscogsApiClient` -- Magic method API calls: `$client->artistGet(['id' => '108713'])` -- Complete API coverage: 65+ endpoints across all Discogs areas +- Magic method API calls: `$client->artistGet(['id' => '5590213'])` +- Complete API coverage: 60 endpoints across all Discogs areas - Multiple authentication methods: OAuth, Personal Token, or anonymous - Modern PHP 8.1โ€“8.5 support with strict typing - 100% test coverage with 43 comprehensive tests diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md new file mode 100644 index 0000000..189a2bf --- /dev/null +++ b/DEVELOPMENT.md @@ -0,0 +1,134 @@ +# Development Guide + +This guide is for contributors and developers working on the php-discogs-api library 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) +composer analyse + +# Code style check (PSR-12) +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** +- **Total Coverage**: 100% lines, methods, and classes covered + +### 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) | +| `DISCOGS_OAUTH_TOKEN` | OAuth access token (optional) | OAuth flow result | +| `DISCOGS_OAUTH_TOKEN_SECRET` | OAuth token secret (optional) | OAuth flow result | + +### Test Levels + +#### 1. Public API Tests (Always Run) + +- File: `tests/Integration/PublicApiIntegrationTest.php` +- No credentials required +- Tests public endpoints: artists, releases, labels, masters +- Safe for forks and pull requests + +#### 2. Authentication Levels Test (Conditional) + +- File: `tests/Integration/AuthenticationLevelsTest.php` +- Requires all three secrets above +- Tests all four authentication levels: + - Level 1: No auth (public data) + - Level 2: Consumer credentials (search) + - Level 3: Personal token (user data) + - Level 4: OAuth (interactive flow, tested when tokens are available) + +### 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 integration tests (public tests run without credentials, auth tests skip if no credentials) +composer test-integration + +# Run all tests (unit + integration) with detailed output +composer test-all -- --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**: PSR-12 (enforced by PHP-CS-Fixer) +- **Static Analysis**: PHPStan Level 8 +- **Test Coverage**: 100% lines, methods, and classes +- **Dependencies**: Minimal (only Guzzle required) + +## ๐Ÿ” Architecture + +The library consists of only four main classes: + +1. **`DiscogsClient`** - Main API client with magic method calls +2. **`DiscogsClientFactory`** - Factory for creating authenticated clients +3. **`OAuthHelper`** - OAuth 1.0a flow helper +4. **`ConfigCache`** - Service configuration cache + +Simple, focused architecture with minimal dependencies. diff --git a/README.md b/README.md index e4f7554..59da01e 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# โšก Discogs API Client for PHP 8.1+ โ€“ Ultra-Lightweight +# โšก Discogs API Client for PHP 8.1+ โ€“ Lightweight with Maximum Developer Comfort [![Package Version](https://img.shields.io/packagist/v/calliostro/php-discogs-api.svg)](https://packagist.org/packages/calliostro/php-discogs-api) [![Total Downloads](https://img.shields.io/packagist/dt/calliostro/php-discogs-api.svg)](https://packagist.org/packages/calliostro/php-discogs-api) @@ -10,9 +10,7 @@ [![PHPStan Level](https://img.shields.io/badge/PHPStan-level%208-brightgreen.svg)](https://phpstan.org/) [![Code Style](https://img.shields.io/badge/code%20style-PSR12-brightgreen.svg)](https://github.com/FriendsOfPHP/PHP-CS-Fixer) -> **๐Ÿš€ ONLY 2 CLASSES!** The most lightweight Discogs API client for PHP. Zero bloat, maximum performance. - -An **ultra-minimalist** Discogs API client that proves you don't need 20+ classes to build a great API client. Built with modern PHP 8.1+ features, service descriptions, and powered by Guzzle. +> **๐Ÿš€ MINIMAL YET POWERFUL!** Focused, lightweight Discogs API client โ€” as compact as possible while maintaining modern PHP comfort and clean APIs. ## ๐Ÿ“ฆ Installation @@ -20,276 +18,230 @@ An **ultra-minimalist** Discogs API client that proves you don't need 20+ classe composer require calliostro/php-discogs-api ``` -**Important:** You need to [register your application](https://www.discogs.com/settings/developers) at Discogs to get your credentials. For read-only access to public data, no authentication is required. +### Do You Need to Register? -**Symfony Users:** For easier integration, there's also a [Symfony Bundle](https://github.com/calliostro/discogs-bundle) available. +**For basic database access (artists, releases, labels):** No registration needed -## ๐Ÿš€ Quick Start +- Install and start using basic endpoints immediately -### Basic Usage +**For search and user features:** Registration required -```php -artistGet([ - 'id' => '45031' // Pink Floyd -]); +**Public data (no registration needed):** -$release = $discogs->releaseGet([ - 'id' => '249504' // Nirvana - Nevermind -]); +```php +$discogs = DiscogsClientFactory::create(); -echo "Artist: " . $artist['name'] . "\n"; -echo "Release: " . $release['title'] . "\n"; +$artist = $discogs->getArtist(5590213); // Billie Eilish +$release = $discogs->getRelease(19929817); // Olivia Rodrigo - Sour +$label = $discogs->getLabel(2311); // Interscope Records ``` -### Collection and Marketplace +**Search with consumer credentials:** ```php -search('Billie Eilish', 'artist'); +$releases = $discogs->listArtistReleases(4470662, 'year', 'desc', 50); + +// Named parameters (PHP 8.0+, recommended for clarity) +$results = $discogs->search(query: 'Taylor Swift', type: 'release'); +$releases = $discogs->listArtistReleases( + artistId: 4470662, + sort: 'year', + sortOrder: 'desc', + perPage: 25 +); +``` -// Collection management -$folders = $discogs->collectionFolders(['username' => 'your-username']); -$folder = $discogs->collectionFolderGet(['username' => 'your-username', 'folder_id' => '1']); -$items = $discogs->collectionItems(['username' => 'your-username', 'folder_id' => '0']); +**Your collections (personal token):** -// Add release to a collection -$addResult = $discogs->collectionAddRelease([ - 'username' => 'your-username', - 'folder_id' => '1', - 'release_id' => '249504' -]); +```php +$discogs = DiscogsClientFactory::createWithPersonalAccessToken('token'); -// Wantlist management -$wantlist = $discogs->wantlistGet(['username' => 'your-username']); -$addToWantlist = $discogs->wantlistAdd([ - 'username' => 'your-username', - 'release_id' => '249504', - 'notes' => 'Looking for mint condition' -]); +$collection = $discogs->listCollectionFolders('your-username'); +$wantlist = $discogs->getUserWantlist('your-username'); -// Marketplace operations -$inventory = $discogs->inventoryGet(['username' => 'your-username']); -$orders = $discogs->ordersGet(['status' => 'Shipped']); - -// Create a marketplace listing -$listing = $discogs->listingCreate([ - 'release_id' => '249504', - 'condition' => 'Near Mint (NM or M-)', - 'sleeve_condition' => 'Very Good Plus (VG+)', - 'price' => '25.00', - 'status' => 'For Sale' -]); +// Add to the collection with named parameters +$discogs->addToCollection( + username: 'your-username', + folderId: 1, + releaseId: 30359313 +); ``` -### Database Search and Discovery +**Multi-user apps (OAuth):** ```php -search(['q' => 'Pink Floyd', 'type' => 'artist']); -$releases = $discogs->artistReleases(['id' => '45031', 'sort' => 'year']); +$discogs = DiscogsClientFactory::createWithOAuth('key', 'secret', 'oauth_token', 'oauth_secret'); -// Master release versions -$master = $discogs->masterGet(['id' => '18512']); -$versions = $discogs->masterVersions(['id' => '18512']); - -// Label information -$label = $discogs->labelGet(['id' => '1']); // Warp Records -$labelReleases = $discogs->labelReleases(['id' => '1']); +$identity = $discogs->getIdentity(); ``` ## โœจ Key Features -- **Ultra-Lightweight** โ€“ Only 2 classes, ~234 lines of logic + service descriptions -- **Complete API Coverage** โ€“ All 60+ Discogs API endpoints supported -- **Direct API Calls** โ€“ `$client->artistGet()` maps to `/artists/{id}`, no abstractions -- **Type Safe + IDE Support** โ€“ Full PHP 8.1+ types, PHPStan Level 8, method autocomplete -- **Future-Ready** โ€“ PHP 8.5 compatible (beta/dev testing) -- **Pure Guzzle** โ€“ Modern HTTP client, no custom transport layers -- **Well Tested** โ€“ 100% test coverage, PSR-12 compliant +- **Simple Setup** โ€“ Works immediately with public data, easy authentication for advanced features +- **Complete API Coverage** โ€“ All 60 Discogs API endpoints supported +- **Clean Parameter API** โ€“ Natural method calls: `getArtist(123)` with named parameter support +- **Lightweight Focus** โ€“ Minimal codebase with only essential dependencies +- **Modern PHP Comfort** โ€“ Full IDE support, type safety, PHPStan Level 8 without bloat - **Secure Authentication** โ€“ Full OAuth and Personal Access Token support +- **Well Tested** โ€“ 100% test coverage, PSR-12 compliant +- **Future-Ready** โ€“ PHP 8.1โ€“8.5 compatible (beta/dev testing) +- **Pure Guzzle** โ€“ Modern HTTP client, no custom transport layers ## ๐ŸŽต All Discogs API Methods as Direct Calls -- **Database Methods** โ€“ search(), artistGet(), artistReleases(), releaseGet(), releaseRatingGet(), releaseRatingPut(), releaseRatingDelete(), releaseRatingCommunity(), releaseStats(), masterGet(), masterVersions(), labelGet(), labelReleases() -- **User Identity Methods** โ€“ identityGet(), userGet(), userEdit(), userSubmissions(), userContributions(), userLists() -- **Collection Methods** โ€“ collectionFolders(), collectionFolderGet(), collectionFolderCreate(), collectionFolderEdit(), collectionFolderDelete(), collectionItems(), collectionItemsByRelease(), collectionAddRelease(), collectionEditRelease(), collectionRemoveRelease(), collectionCustomFields(), collectionEditField(), collectionValue() -- **Wantlist Methods** โ€“ wantlistGet(), wantlistAdd(), wantlistEdit(), wantlistRemove() -- **Marketplace Methods** โ€“ inventoryGet(), listingGet(), listingCreate(), listingUpdate(), listingDelete(), marketplaceFee(), marketplaceFeeCurrency(), marketplacePriceSuggestions(), marketplaceStats() -- **Order Methods** โ€“ orderGet(), ordersGet(), orderUpdate(), orderMessages(), orderMessageAdd() -- **Inventory Export Methods** โ€“ inventoryExportCreate(), inventoryExportList(), inventoryExportGet(), inventoryExportDownload() -- **Inventory Upload Methods** โ€“ inventoryUploadAdd(), inventoryUploadChange(), inventoryUploadDelete(), inventoryUploadList(), inventoryUploadGet() -- **List Methods** โ€“ listGet() +- **Database Methods** โ€“ search(), getArtist(), listArtistReleases(), getRelease(), updateUserReleaseRating(), deleteUserReleaseRating(), getUserReleaseRating(), getCommunityReleaseRating(), getReleaseStats(), getMaster(), listMasterVersions(), getLabel(), listLabelReleases() +- **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() +- **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() + +*All Discogs API endpoints are supported with clean documentation โ€” see [Discogs API Documentation](https://www.discogs.com/developers/) for complete method reference* -*All 60+ Discogs API endpoints are supported with clean documentation โ€” see [Discogs API Documentation](https://www.discogs.com/developers/) for complete method reference* +> ๐Ÿ’ก **Note:** Some endpoints require special permissions (seller accounts, data ownership). ## ๐Ÿ“‹ Requirements - **php** ^8.1 - **guzzlehttp/guzzle** ^6.5 || ^7.0 -## ๐Ÿ”ง Advanced Configuration +## โš™๏ธ Configuration -### Option 1: Simple Configuration (Recommended) +### Configuration -For basic customizations like timeout or User-Agent, use the ClientFactory: +**Simple (works out of the box):** ```php - 30, - 'headers' => [ - 'User-Agent' => 'MyApp/1.0 (+https://myapp.com)', - ] -]); +$discogs = DiscogsClientFactory::create(); ``` -### Option 2: Advanced Guzzle Configuration - -For advanced HTTP client features (middleware, interceptors, etc.), create your own Guzzle client: +**Advanced (middleware, custom options, etc.):** ```php -push(Middleware::retry( + fn ($retries, $request, $response) => $retries < 3 && $response?->getStatusCode() === 429, + fn ($retries) => 1000 * 2 ** ($retries + 1) // Rate limit handling +)); -$httpClient = new Client([ +$discogs = DiscogsClientFactory::create([ 'timeout' => 30, - 'connect_timeout' => 10, + 'handler' => $handler, 'headers' => [ 'User-Agent' => 'MyApp/1.0 (+https://myapp.com)', ] ]); - -// Direct usage -$discogs = new DiscogsApiClient($httpClient); - -// Or via ClientFactory -$discogs = ClientFactory::create('MyApp/1.0', $httpClient); ``` -> **๐Ÿ’ก Note:** By default, the client uses `DiscogsClient/3.0 (+https://github.com/calliostro/php-discogs-api)` as User-Agent. You can override this by setting custom headers as shown above. +> ๐Ÿ’ก **Note:** By default, the client uses `DiscogsClient/4.0.0 +https://github.com/calliostro/php-discogs-api` as User-Agent. You can override this by setting custom headers as shown above. ## ๐Ÿ” Authentication -Discogs supports different authentication flows: - -### Personal Access Token (Recommended) - -For accessing your own account data, use a Personal Access Token from [Discogs Developer Settings](https://www.discogs.com/settings/developers): - -```php -identityGet(); -$collection = $discogs->collectionFolders(['username' => 'your-username']); -``` - -### OAuth 1.0a Authentication +### Complete OAuth Flow Example -For building applications that access user data on their behalf: +**Step 1: authorize.php** - Redirect user to Discogs ```php identityGet(); -$orders = $discogs->ordersGet(); -``` +use Calliostro\Discogs\OAuthHelper; -> **๐Ÿ’ก Note:** Implementing the complete OAuth flow is complex and beyond the scope of this README. For detailed examples, see the [Discogs OAuth Documentation](https://www.discogs.com/developers/#page:authentication,header:authentication-oauth-flow). +$consumerKey = 'your-consumer-key'; +$consumerSecret = 'your-consumer-secret'; +$callbackUrl = 'https://yourapp.com/callback.php'; -## ๐Ÿงช Testing +$oauth = new OAuthHelper(); +$requestToken = $oauth->getRequestToken($consumerKey, $consumerSecret, $callbackUrl); -Run the test suite: +$_SESSION['oauth_token'] = $requestToken['oauth_token']; +$_SESSION['oauth_token_secret'] = $requestToken['oauth_token_secret']; -```bash -composer test -``` - -Run static analysis: - -```bash -composer analyse +$authUrl = $oauth->getAuthorizationUrl($requestToken['oauth_token']); +header("Location: {$authUrl}"); +exit; ``` -Check code style: - -```bash -composer cs -``` +**Step 2: callback.php** - Handle Discogs callback -## ๐Ÿ“š API Documentation Reference - -For complete API documentation including all available parameters, visit the [Discogs API Documentation](https://www.discogs.com/developers/). +```php +getAccessToken( + $consumerKey, + $consumerSecret, + $_SESSION['oauth_token'], + $_SESSION['oauth_token_secret'], + $verifier +); -- `collectionFolders($params)` โ€“ Get user's collection folders -- `collectionItems($params)` โ€“ Get collection items by folder -- `collectionFolderGet($params)` โ€“ Get specific collection folder +$oauthToken = $accessToken['oauth_token']; +$oauthSecret = $accessToken['oauth_token_secret']; -#### User Methods +// Store tokens for future use +$_SESSION['oauth_token'] = $oauthToken; +$_SESSION['oauth_token_secret'] = $oauthSecret; -- `identityGet($params)` โ€“ Get authenticated user's identity (auth required) -- `userGet($params)` โ€“ Get user profile information -- `wantlistGet($params)` โ€“ Get user's wantlist +$discogs = DiscogsClientFactory::createWithOAuth($consumerKey, $consumerSecret, $oauthToken, $oauthSecret); +$identity = $discogs->getIdentity(); +echo "Hello " . $identity['username']; +``` ## ๐Ÿค Contributing -1. Fork the repository -2. Create a feature branch (`git checkout -b feature/amazing-feature`) -3. Commit your changes (`git commit -m 'Add amazing feature'`) -4. Push to the branch (`git push origin feature/amazing-feature`) -5. Open a Pull Request - -Please ensure your code follows PSR-12 standards and includes tests. +Contributions are welcome! See [DEVELOPMENT.md](DEVELOPMENT.md) for detailed setup instructions, testing guide, and development workflow. ## ๐Ÿ“„ License -This project is licensed under the MIT License โ€” see the [LICENSE](LICENSE) file for details. +MIT License โ€“ see [LICENSE](LICENSE) file. ## ๐Ÿ™ Acknowledgments -- [Discogs](https://www.discogs.com/) for providing the excellent music database API -- [Guzzle](https://docs.guzzlephp.org/) for the robust HTTP client -- [ricbra/php-discogs-api](https://github.com/ricbra/php-discogs-api) and [AnssiAhola/php-discogs-api](https://github.com/AnssiAhola/php-discogs-api) for the original inspiration +- [Discogs](https://www.discogs.com/) for the excellent API +- [Guzzle](https://docs.guzzlephp.org/) for an HTTP client +- Previous PHP Discogs implementations for inspiration + +--- -> **โญ Star this repo if you find it useful! It helps others discover this lightweight solution.** +> โญ **Star this repo if you find it useful!** diff --git a/UPGRADE.md b/UPGRADE.md index 7059e16..7c5abd1 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -1,192 +1,336 @@ -# Upgrade Guide: v2.x โ†’ v3.0 +# Upgrade Guide: v3.x to v4.0 -This guide covers the breaking changes when upgrading from php-discogs-api v2.x to v3.0. +This guide helps you migrate from php-discogs-api v3.x to v4.0.0. -## Overview +## ๐Ÿšจ Breaking Changes Overview -v3.0 is a **complete rewrite** with an ultra-lightweight architecture. Every aspect of the API has changed. +**v4.0.0 introduces major breaking changes** for the cleanest, most lightweight PHP Discogs API client: -## Requirements Changes +### **Breaking Change #1: Clean Parameter API** -### PHP Version +**Array parameters completely removed** โ€“ Clean method signatures with positional parameters: -- **Before (v2.x)**: PHP 7.3+ -- **After (v3.0)**: PHP 8.1+ (strict requirement) +```php +// OLD (v3.x) +$artist = $discogs->artistGet(['id' => 5590213]); +$search = $discogs->search(['q' => 'Billie Eilish', 'type' => 'artist']); -### Dependencies +// NEW (v4.0) +$artist = $discogs->getArtist(5590213); +$search = $discogs->search('Billie Eilish', 'artist'); +``` -- **Before**: Guzzle Services, Command, OAuth Subscriber -- **After**: Pure Guzzle HTTP client only +### **Breaking Change #2: Consistent Method Naming** -## Namespace Changes +**All method names changed**: `artistGet()` โ†’ `getArtist()`, `userEdit()` โ†’ `updateUser()` -```php - ['User-Agent' => 'MyApp/1.0'] -]); - -// With authentication -$client = ClientFactory::factory([ - 'headers' => [ - 'User-Agent' => 'MyApp/1.0', - 'Authorization' => 'Discogs token=your-token' - ] -]); +// v3.x (OLD - arrays with old method names) +$artist = $discogs->artistGet(['id' => 5590213]); +$search = $discogs->search(['q' => 'Billie Eilish', 'type' => 'artist', 'per_page' => 50]); +$collection = $discogs->collectionItems(['username' => 'user', 'folder_id' => 0, 'per_page' => 25]); + +// v4.0 (NEW - positional parameters) +$artist = $discogs->getArtist(5590213); + +// Traditional positional (with many nulls) +$search = $discogs->search('Billie Eilish', 'artist', null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, null, 50); + +// Better: Named parameters (PHP 8.0+, recommended) +$search = $discogs->search(query: 'Billie Eilish', type: 'artist', perPage: 50); +$collection = $discogs->listCollectionItems(username: 'user', folderId: 0, perPage: 25); ``` -### After (v3.0) +### Parameter Order Reference -```php -artistGet(['id' => '5590213']); +$releases = $discogs->artistReleases(['id' => '5590213']); +$release = $discogs->releaseGet(['id' => '19929817']); +$master = $discogs->masterGet(['id' => '1524311']); +$label = $discogs->labelGet(['id' => '2311']); +``` -// Personal Access Token (recommended) -$client = ClientFactory::createWithToken('your-token', 'MyApp/1.0'); +**v4.0:** -// OAuth -$client = ClientFactory::createWithOAuth('token', 'secret', 'MyApp/1.0'); +```php +// Positional parameters +$artist = $discogs->getArtist(5590213); +$releases = $discogs->listArtistReleases(5590213); +$release = $discogs->getRelease(19929817); +$master = $discogs->getMaster(1524311); +$label = $discogs->getLabel(2311); + +// Named parameters (PHP 8.0+, better for methods with many parameters) +$releases = $discogs->listArtistReleases( + artistId: 5590213, + sort: 'year', + sortOrder: 'desc', + perPage: 50 +); ``` -## API Method Calls +### Marketplace Methods + +**v3.x:** + +```php +$inventory = $discogs->inventoryGet(['username' => 'example']); +$orders = $discogs->ordersGet(['status' => 'Shipped']); +$listing = $discogs->listingCreate(['release_id' => '19929817', 'condition' => 'Near Mint (NM or M-)', 'price' => '25.00']); +$discogs->listingUpdate(['listing_id' => '123', 'price' => '30.00']); +$discogs->listingDelete(['listing_id' => '123']); +$order = $discogs->orderGet(['order_id' => '123']); +$messages = $discogs->orderMessages(['order_id' => '123']); +$fee = $discogs->fee(['price' => '25.00']); +``` -### Before (v2.x): Guzzle Services Commands +**v4.0:** ```php -getUserInventory('example'); +$orders = $discogs->getMarketplaceOrders('Shipped'); +$listing = $discogs->createMarketplaceListing(19929817, 'Near Mint (NM or M-)', 25.00, 'For Sale'); +$discogs->updateMarketplaceListing(123, 'Near Mint (NM or M-)', null, 30.00); +$discogs->deleteMarketplaceListing(123); +$order = $discogs->getMarketplaceOrder(123); +$messages = $discogs->getMarketplaceOrderMessages(123); +$fee = $discogs->getMarketplaceFee(25.00); + +// Named parameters (clearer for complex calls) +$listing = $discogs->createMarketplaceListing( + releaseId: 19929817, + condition: 'Near Mint (NM or M-)', + price: 25.00, + status: 'For Sale', + comments: 'Mint condition, never played' +); +``` + +## ๐Ÿ“‹ Complete Method Migration Table + +### Database Methods + +| v3.x Method | v4.0 Method | +|----------------------------|-------------------------------| +| `artistGet()` | `getArtist()` | +| `artistReleases()` | `listArtistReleases()` | +| `releaseGet()` | `getRelease()` | +| `releaseRatingGet()` | `getReleaseRatingByUser()` | +| `releaseRatingPut()` | `setReleaseRating()` | +| `releaseRatingDelete()` | `deleteReleaseRating()` | +| `releaseRatingCommunity()` | `getCommunityReleaseRating()` | +| `releaseStats()` | `getReleaseStats()` | +| `masterGet()` | `getMaster()` | +| `masterVersions()` | `listMasterVersions()` | +| `labelGet()` | `getLabel()` | +| `labelReleases()` | `listLabelReleases()` | + +### User & Identity Methods + +| v3.x Method | v4.0 Method | +|-----------------------|---------------------------| +| `identityGet()` | `getIdentity()` | +| `userGet()` | `getUser()` | +| `userEdit()` | `updateUser()` | +| `userSubmissions()` | `listUserSubmissions()` | +| `userContributions()` | `listUserContributions()` | +| `userLists()` | `getUserLists()` | + +### Collection Methods + +| v3.x Method | v4.0 Method | +|------------------------------|---------------------------------| +| `collectionFolders()` | `listCollectionFolders()` | +| `collectionFolderGet()` | `getCollectionFolder()` | +| `collectionFolderCreate()` | `createCollectionFolder()` | +| `collectionFolderEdit()` | `updateCollectionFolder()` | +| `collectionFolderDelete()` | `deleteCollectionFolder()` | +| `collectionItems()` | `listCollectionItems()` | +| `collectionItemsByRelease()` | `getCollectionItemsByRelease()` | +| `collectionAddRelease()` | `addToCollection()` | +| `collectionEditRelease()` | `updateCollectionItem()` | +| `collectionRemoveRelease()` | `removeFromCollection()` | +| `collectionCustomFields()` | `getCustomFields()` | +| `collectionEditField()` | `setCustomFields()` | +| `collectionValue()` | `getCollectionValue()` | + +### Wantlist Methods + +| v3.x Method | v4.0 Method | +|--------------------|------------------------| +| `wantlistGet()` | `getUserWantlist()` | +| `wantlistAdd()` | `addToWantlist()` | +| `wantlistEdit()` | `updateWantlistItem()` | +| `wantlistRemove()` | `removeFromWantlist()` | + +### Marketplace & Inventory Methods + +| v3.x Method | v4.0 Method | +|----------------------|------------------------------------| +| `inventoryGet()` | `getUserInventory()` | +| `listingGet()` | `getMarketplaceListing()` | +| `listingCreate()` | `createMarketplaceListing()` | +| `listingUpdate()` | `updateMarketplaceListing()` | +| `listingDelete()` | `deleteMarketplaceListing()` | +| `orderGet()` | `getMarketplaceOrder()` | +| `ordersGet()` | `getMarketplaceOrders()` | +| `orderUpdate()` | `updateMarketplaceOrder()` | +| `orderMessages()` | `getMarketplaceOrderMessages()` | +| `orderMessageAdd()` | `addMarketplaceOrderMessage()` | +| `fee()` | `getMarketplaceFee()` | +| `feeByCurrency()` | `getMarketplaceFeeByCurrency()` | +| `priceSuggestions()` | `getMarketplacePriceSuggestions()` | +| `marketplaceStats()` | `getMarketplaceStats()` | + +### Export/Import Methods + +| v3.x Method | v4.0 Method | +|-----------------------------|-----------------------------| +| `inventoryExportCreate()` | `createInventoryExport()` | +| `inventoryExportList()` | `listInventoryExports()` | +| `inventoryExportGet()` | `getInventoryExport()` | +| `inventoryExportDownload()` | `downloadInventoryExport()` | +| `inventoryUploadAdd()` | `addInventoryUpload()` | +| `inventoryUploadChange()` | `changeInventoryUpload()` | +| `inventoryUploadDelete()` | `deleteInventoryUpload()` | +| `inventoryUploadList()` | `listInventoryUploads()` | +| `inventoryUploadGet()` | `getInventoryUpload()` | + +### User Lists Methods + +| v3.x Method | v4.0 Method | +|---------------|------------------| +| `userLists()` | `getUserLists()` | +| `listGet()` | `getUserList()` | + +## ๐Ÿ› ๏ธ Migration Helper Script + +Find and replace common method calls in your project: + +```bash +# Find old method calls +grep -r "artistGet\|releaseGet\|userEdit\|collectionFolders\|wantlistGet\|inventoryGet\|listingCreate\|ordersGet" /path/to/your/project + +# Replace common patterns (backup your files first!) +sed -i 's/DiscogsApiClient/DiscogsClient/g' /path/to/your/project/*.php +sed -i 's/ClientFactory/DiscogsClientFactory/g' /path/to/your/project/*.php +sed -i 's/artistGet(/getArtist(/g' /path/to/your/project/*.php +sed -i 's/releaseGet(/getRelease(/g' /path/to/your/project/*.php +sed -i 's/userEdit(/updateUser(/g' /path/to/your/project/*.php +``` + +## ๐Ÿ“ What Stays The Same + +- **Return Values**: All API responses remain identical +- **HTTP Client**: Still uses Guzzle (^6.5 || ^7.0) +- **PHP Requirements**: Still requires PHP ^8.1 -// Search -$results = $client->search(['q' => 'Nirvana', 'type' => 'artist']); +## ๐Ÿ” Authentication Changes -// Get artist (command-based) -$artist = $client->getArtist(['id' => '45031']); +The authentication implementation has been **significantly improved**: -// Get releases -$releases = $client->getArtistReleases(['id' => '45031']); +### What Changed -// Marketplace -$inventory = $client->getInventory(['username' => 'user']); +- **Personal Access Token**: Now uses the proper Discogs Auth format +- **OAuth 1.0a**: RFC 5849 compliant with PLAINTEXT signature method +- **Factory Method Renamed**: `createWithToken()` โ†’ `createWithPersonalAccessToken()` + +### Migration Required + +**v3.x:** + +```php +$discogs = ClientFactory::createWithToken('your-personal-access-token'); ``` -### After (v3.0): Magic Method Calls +**v4.0:** ```php -search(['q' => 'Nirvana', 'type' => 'artist']); +**โš ๏ธ Important**: Personal Access Token now requires consumer credentials. -// Get artist (magic method) -$artist = $client->artistGet(['id' => '45031']); +## ๐ŸŽฏ Migration Checklist -// Get releases (magic method) -$releases = $client->artistReleases(['id' => '45031']); +- **Update class names**: `DiscogsApiClient` โ†’ `DiscogsClient`, `ClientFactory` โ†’ `DiscogsClientFactory` +- **Update method calls** using the migration table above +- **Update authentication** for personal access tokens +- **Run tests** to ensure all calls are updated +- **Update composer.json** to `^4.0` version constraint -// Marketplace (magic method) -$inventory = $client->inventoryGet(['username' => 'user']); -``` +## ๐Ÿ’ก Migration Tips + +- **Use IDE Search & Replace**: Most IDEs support project-wide search and replace +- **Update incrementally**: Migrate one method type at a time (database, collection, etc.) +- **Run tests frequently**: Catch any missed method calls early +- **Check error logs**: v4.0 provides clear error messages for unknown operations + +## ๐Ÿ†˜ Need Help? + +- **Issue Tracker**: [GitHub Issues](https://github.com/calliostro/php-discogs-api/issues) +- **Documentation**: All new method names are documented in the [README.md](README.md) + +--- + +## Previous Versions -## Method Name Mapping - -| v2.x Command | v3.0 Magic Method | Parameters | -|------------------------------|-------------------------|-----------------------------| -| `getArtist` | `artistGet` | `['id' => 'string']` | -| `getArtistReleases` | `artistReleases` | `['id' => 'string']` | -| `getRelease` | `releaseGet` | `['id' => 'string']` | -| `getMaster` | `masterGet` | `['id' => 'string']` | -| `getMasterVersions` | `masterVersions` | `['id' => 'string']` | -| `getLabel` | `labelGet` | `['id' => 'string']` | -| `getLabelReleases` | `labelReleases` | `['id' => 'string']` | -| `search` | `search` | `['q' => 'string']` | -| `getOAuthIdentity` | `identityGet` | `[]` | -| `getProfile` | `userGet` | `['username' => 'string']` | -| `getCollectionFolders` | `collectionFolders` | `['username' => 'string']` | -| `getCollectionFolder` | `collectionFolderGet` | `['username', 'folder_id']` | -| `getCollectionItemsByFolder` | `collectionItems` | `['username', 'folder_id']` | -| `getInventory` | `inventoryGet` | `['username' => 'string']` | -| `addInventory` | `inventoryUploadAdd` | `[...]` | -| `deleteInventory` | `inventoryUploadDelete` | `[...]` | -| `getOrder` | `orderGet` | `['order_id' => 'string']` | -| `getOrders` | `ordersGet` | `[]` | -| `changeOrder` | `orderUpdate` | `[...]` | -| `getOrderMessages` | `orderMessages` | `['order_id' => 'string']` | -| `addOrderMessage` | `orderMessageAdd` | `[...]` | -| `createListing` | `listingCreate` | `[...]` | -| `changeListing` | `listingUpdate` | `[...]` | -| `deleteListing` | `listingDelete` | `[...]` | -| `getUserLists` | `userLists` | `['username' => 'string']` | -| `getLists` | `listGet` | `['list_id' => 'string']` | -| `getWantlist` | `wantlistGet` | `['username' => 'string']` | - -## Configuration Changes - -### Service Configuration - -- **Before**: Complex Guzzle Services YAML/JSON definitions -- **After**: Simple PHP array in `resources/service.php` - -### Throttling - -- **Before**: `ThrottleSubscriber` with Guzzle middlewares -- **After**: Handle rate limiting in your application layer - -### Error Handling - -- **Before**: Guzzle Services exceptions -- **After**: Standard `RuntimeException` with clear messages - -## Testing Your Migration - -1. **Update composer.json**: - - ```json - { - "require": { - "calliostro/php-discogs-api": "^3.0" - } - } - ``` - -2. **Update namespace imports** -3. **Replace client creation calls** -4. **Update method calls using the mapping table** -5. **Test your application thoroughly** - -## Benefits of v3.0 - -- **Ultra-lightweight**: Two classes instead of complex services -- **Better performance**: Direct HTTP calls, no command layer overhead -- **Modern PHP**: PHP 8.1+ features, strict typing, better IDE support -- **Easier testing**: Simple mock-friendly HTTP client -- **Cleaner code**: Magic methods eliminate boilerplate -- **Better maintainability**: Simplified architecture - -## Need Help? - -- Check the [README.md](README.md) for complete v3.0 documentation -- Review the [CHANGELOG.md](CHANGELOG.md) for detailed changes -- Open an issue if you encounter migration problems +For upgrading from v2.x to v3.0, see the [v3.0 changelog](https://github.com/calliostro/php-discogs-api/releases/tag/v3.0.0). diff --git a/composer.json b/composer.json index 7ba4c20..c1b6432 100644 --- a/composer.json +++ b/composer.json @@ -1,6 +1,6 @@ { "name": "calliostro/php-discogs-api", - "description": "Ultra-lightweight Discogs API client for PHP 8.1+ with modern Guzzle-based implementation โ€” Only two classes, service descriptions, zero bloat", + "description": "Lightweight Discogs API client for PHP 8.1+ with modern developer comfort โ€” Clean parameter API and minimal dependencies", "type": "library", "keywords": [ "php", @@ -14,7 +14,10 @@ "guzzle", "library", "php8", - "lightweight" + "lightweight", + "clean-api", + "modern-php", + "minimal-dependencies" ], "license": "MIT", "authors": [ @@ -55,10 +58,14 @@ } }, "scripts": { - "test": "vendor/bin/phpunit", + "test": "vendor/bin/phpunit --testsuite=\"Unit Tests\"", + "test-integration": "vendor/bin/phpunit --testsuite=\"Integration Tests\"", + "test-all": "vendor/bin/phpunit --testsuite=\"All Tests\"", + "test-coverage": "vendor/bin/phpunit --testsuite=\"Unit Tests\" --coverage-html coverage --coverage-clover coverage.xml", + "test-coverage-all": "vendor/bin/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": "phpstan analyse src/ --level=8" + "analyse": "phpstan analyse src/ tests/ --level=8" }, "minimum-stability": "stable", "prefer-stable": true diff --git a/phpunit.xml.dist b/phpunit.xml.dist index 6902cb9..8e294c9 100644 --- a/phpunit.xml.dist +++ b/phpunit.xml.dist @@ -11,7 +11,13 @@ displayDetailsOnIncompleteTests="true" displayDetailsOnSkippedTests="true"> - + + tests/Unit + + + tests/Integration + + tests @@ -23,5 +29,12 @@ + + + + + + + diff --git a/resources/service.php b/resources/service.php index 3ef24db..530a2cd 100644 --- a/resources/service.php +++ b/resources/service.php @@ -6,33 +6,33 @@ // =========================== // DATABASE METHODS // =========================== - 'artist.get' => [ + 'getArtist' => [ 'httpMethod' => 'GET', - 'uri' => 'artists/{id}', + 'uri' => 'artists/{artist_id}', 'parameters' => [ - 'id' => ['required' => true], + 'artist_id' => ['required' => true], ], ], - 'artist.releases' => [ + 'listArtistReleases' => [ 'httpMethod' => 'GET', - 'uri' => 'artists/{id}/releases', + 'uri' => 'artists/{artist_id}/releases', 'parameters' => [ - 'id' => ['required' => true], + 'artist_id' => ['required' => true], 'sort' => ['required' => false], 'sort_order' => ['required' => false], 'per_page' => ['required' => false], 'page' => ['required' => false], ], ], - 'release.get' => [ + 'getRelease' => [ 'httpMethod' => 'GET', - 'uri' => 'releases/{id}', + 'uri' => 'releases/{release_id}', 'parameters' => [ - 'id' => ['required' => true], + 'release_id' => ['required' => true], 'curr_abbr' => ['required' => false], ], ], - 'release.rating.get' => [ + 'getUserReleaseRating' => [ 'httpMethod' => 'GET', 'uri' => 'releases/{release_id}/rating/{username}', 'parameters' => [ @@ -40,7 +40,7 @@ 'username' => ['required' => true], ], ], - 'release.rating.put' => [ + 'updateUserReleaseRating' => [ 'httpMethod' => 'PUT', 'uri' => 'releases/{release_id}/rating/{username}', 'requiresAuth' => true, @@ -50,7 +50,7 @@ 'rating' => ['required' => true], ], ], - 'release.rating.delete' => [ + 'deleteUserReleaseRating' => [ 'httpMethod' => 'DELETE', 'uri' => 'releases/{release_id}/rating/{username}', 'requiresAuth' => true, @@ -59,32 +59,32 @@ 'username' => ['required' => true], ], ], - 'release.rating.community' => [ + 'getCommunityReleaseRating' => [ 'httpMethod' => 'GET', 'uri' => 'releases/{release_id}/rating', 'parameters' => [ 'release_id' => ['required' => true], ], ], - 'release.stats' => [ + 'getReleaseStats' => [ 'httpMethod' => 'GET', 'uri' => 'releases/{release_id}/stats', 'parameters' => [ 'release_id' => ['required' => true], ], ], - 'master.get' => [ + 'getMaster' => [ 'httpMethod' => 'GET', - 'uri' => 'masters/{id}', + 'uri' => 'masters/{master_id}', 'parameters' => [ - 'id' => ['required' => true], + 'master_id' => ['required' => true], ], ], - 'master.versions' => [ + 'listMasterVersions' => [ 'httpMethod' => 'GET', - 'uri' => 'masters/{id}/versions', + 'uri' => 'masters/{master_id}/versions', 'parameters' => [ - 'id' => ['required' => true], + 'master_id' => ['required' => true], 'per_page' => ['required' => false], 'page' => ['required' => false], 'format' => ['required' => false], @@ -95,18 +95,18 @@ 'sort_order' => ['required' => false], ], ], - 'label.get' => [ + 'getLabel' => [ 'httpMethod' => 'GET', - 'uri' => 'labels/{id}', + 'uri' => 'labels/{label_id}', 'parameters' => [ - 'id' => ['required' => true], + 'label_id' => ['required' => true], ], ], - 'label.releases' => [ + 'listLabelReleases' => [ 'httpMethod' => 'GET', - 'uri' => 'labels/{id}/releases', + 'uri' => 'labels/{label_id}/releases', 'parameters' => [ - 'id' => ['required' => true], + 'label_id' => ['required' => true], 'per_page' => ['required' => false], 'page' => ['required' => false], ], @@ -138,22 +138,248 @@ ], ], + // =========================== + // MARKETPLACE METHODS + // =========================== + 'getUserInventory' => [ + 'httpMethod' => 'GET', + 'uri' => 'users/{username}/inventory', + 'parameters' => [ + 'username' => ['required' => true], + 'status' => ['required' => false], + 'sort' => ['required' => false], + 'sort_order' => ['required' => false], + 'per_page' => ['required' => false], + 'page' => ['required' => false], + ], + ], + 'getMarketplaceListing' => [ + 'httpMethod' => 'GET', + 'uri' => 'marketplace/listings/{listing_id}', + 'parameters' => [ + 'listing_id' => ['required' => true], + 'curr_abbr' => ['required' => false], + ], + ], + 'createMarketplaceListing' => [ + 'httpMethod' => 'POST', + 'uri' => 'marketplace/listings', + 'requiresAuth' => true, + 'parameters' => [ + 'release_id' => ['required' => true], + 'condition' => ['required' => true], + 'sleeve_condition' => ['required' => false], + 'price' => ['required' => true], + 'comments' => ['required' => false], + 'allow_offers' => ['required' => false], + 'status' => ['required' => true], + 'external_id' => ['required' => false], + 'location' => ['required' => false], + 'weight' => ['required' => false], + 'format_quantity' => ['required' => false], + ], + ], + 'updateMarketplaceListing' => [ + 'httpMethod' => 'POST', + 'uri' => 'marketplace/listings/{listing_id}', + 'requiresAuth' => true, + 'parameters' => [ + 'listing_id' => ['required' => true], + 'release_id' => ['required' => true], + 'condition' => ['required' => true], + 'sleeve_condition' => ['required' => false], + 'price' => ['required' => true], + 'comments' => ['required' => false], + 'allow_offers' => ['required' => false], + 'status' => ['required' => true], + 'external_id' => ['required' => false], + 'location' => ['required' => false], + 'weight' => ['required' => false], + 'format_quantity' => ['required' => false], + 'curr_abbr' => ['required' => false], + ], + ], + 'deleteMarketplaceListing' => [ + 'httpMethod' => 'DELETE', + 'uri' => 'marketplace/listings/{listing_id}', + 'requiresAuth' => true, + 'parameters' => [ + 'listing_id' => ['required' => true], + 'curr_abbr' => ['required' => false], + ], + ], + 'getMarketplaceOrder' => [ + 'httpMethod' => 'GET', + 'uri' => 'marketplace/orders/{order_id}', + 'requiresAuth' => true, + 'parameters' => [ + 'order_id' => ['required' => true], + ], + ], + 'getMarketplaceOrders' => [ + 'httpMethod' => 'GET', + 'uri' => 'marketplace/orders', + 'requiresAuth' => true, + 'parameters' => [ + 'status' => ['required' => false], + 'sort' => ['required' => false], + 'sort_order' => ['required' => false], + 'created_before' => ['required' => false], + 'created_after' => ['required' => false], + 'archived' => ['required' => false], + ], + ], + 'updateMarketplaceOrder' => [ + 'httpMethod' => 'POST', + 'uri' => 'marketplace/orders/{order_id}', + 'requiresAuth' => true, + 'parameters' => [ + 'order_id' => ['required' => true], + 'status' => ['required' => false], + 'shipping' => ['required' => false], + ], + ], + 'getMarketplaceOrderMessages' => [ + 'httpMethod' => 'GET', + 'uri' => 'marketplace/orders/{order_id}/messages', + 'requiresAuth' => true, + 'parameters' => [ + 'order_id' => ['required' => true], + ], + ], + 'addMarketplaceOrderMessage' => [ + 'httpMethod' => 'POST', + 'uri' => 'marketplace/orders/{order_id}/messages', + 'requiresAuth' => true, + 'parameters' => [ + 'order_id' => ['required' => true], + 'message' => ['required' => false], + 'status' => ['required' => false], + ], + ], + // NOTE: getMarketplaceFee endpoints require SELLER ACCOUNT permissions + // https://www.discogs.com/developers/#page:marketplace,header:marketplace-fee-post + 'getMarketplaceFee' => [ + 'httpMethod' => 'GET', + 'uri' => 'marketplace/fee/{price}', + 'requiresAuth' => true, // Seller account required + 'parameters' => [ + 'price' => ['required' => true], + ], + ], + 'getMarketplaceFeeByCurrency' => [ + 'httpMethod' => 'GET', + 'uri' => 'marketplace/fee/{price}/{currency}', + 'requiresAuth' => true, // Seller account required + 'parameters' => [ + 'price' => ['required' => true], + 'currency' => ['required' => true], + ], + ], + 'getMarketplacePriceSuggestions' => [ + 'httpMethod' => 'GET', + 'uri' => 'marketplace/price_suggestions/{release_id}', + 'requiresAuth' => true, // Seller account required + 'parameters' => [ + 'release_id' => ['required' => true], + ], + ], + 'getMarketplaceStats' => [ + 'httpMethod' => 'GET', + 'uri' => 'marketplace/stats/{release_id}', + 'parameters' => [ + 'release_id' => ['required' => true], + 'curr_abbr' => ['required' => false], + ], + ], + + // =========================== + // INVENTORY EXPORT METHODS + // =========================== + 'createInventoryExport' => [ + 'httpMethod' => 'POST', + 'uri' => 'inventory/export', + 'requiresAuth' => true, + ], + 'listInventoryExports' => [ + 'httpMethod' => 'GET', + 'uri' => 'inventory/export', + 'requiresAuth' => true, + ], + 'getInventoryExport' => [ + 'httpMethod' => 'GET', + 'uri' => 'inventory/export/{export_id}', + 'requiresAuth' => true, + 'parameters' => [ + 'export_id' => ['required' => true], + ], + ], + 'downloadInventoryExport' => [ + 'httpMethod' => 'GET', + 'uri' => 'inventory/export/{export_id}/download', + 'requiresAuth' => true, + 'parameters' => [ + 'export_id' => ['required' => true], + ], + ], + + // =========================== + // INVENTORY UPLOAD METHODS + // =========================== + 'addInventoryUpload' => [ + 'httpMethod' => 'POST', + 'uri' => 'inventory/upload/add', + 'requiresAuth' => true, + 'parameters' => [ + 'upload' => ['required' => true], + ], + ], + 'changeInventoryUpload' => [ + 'httpMethod' => 'POST', + 'uri' => 'inventory/upload/change', + 'requiresAuth' => true, + 'parameters' => [ + 'upload' => ['required' => true], + ], + ], + 'deleteInventoryUpload' => [ + 'httpMethod' => 'POST', + 'uri' => 'inventory/upload/delete', + 'requiresAuth' => true, + 'parameters' => [ + 'upload' => ['required' => true], + ], + ], + 'listInventoryUploads' => [ + 'httpMethod' => 'GET', + 'uri' => 'inventory/upload', + 'requiresAuth' => true, + ], + 'getInventoryUpload' => [ + 'httpMethod' => 'GET', + 'uri' => 'inventory/upload/{upload_id}', + 'requiresAuth' => true, + 'parameters' => [ + 'upload_id' => ['required' => true], + ], + ], + // =========================== // USER IDENTITY METHODS // =========================== - 'identity.get' => [ + 'getIdentity' => [ 'httpMethod' => 'GET', 'uri' => 'oauth/identity', 'requiresAuth' => true, ], - 'user.get' => [ + 'getUser' => [ 'httpMethod' => 'GET', 'uri' => 'users/{username}', 'parameters' => [ 'username' => ['required' => true], ], ], - 'user.edit' => [ + 'updateUser' => [ 'httpMethod' => 'POST', 'uri' => 'users/{username}', 'requiresAuth' => true, @@ -166,7 +392,7 @@ 'curr_abbr' => ['required' => false], ], ], - 'user.submissions' => [ + 'listUserSubmissions' => [ 'httpMethod' => 'GET', 'uri' => 'users/{username}/submissions', 'parameters' => [ @@ -175,7 +401,7 @@ 'page' => ['required' => false], ], ], - 'user.contributions' => [ + 'listUserContributions' => [ 'httpMethod' => 'GET', 'uri' => 'users/{username}/contributions', 'parameters' => [ @@ -186,16 +412,16 @@ ], // =========================== - // COLLECTION METHODS + // USER COLLECTION METHODS // =========================== - 'collection.folders' => [ + 'listCollectionFolders' => [ 'httpMethod' => 'GET', 'uri' => 'users/{username}/collection/folders', 'parameters' => [ 'username' => ['required' => true], ], ], - 'collection.folder.get' => [ + 'getCollectionFolder' => [ 'httpMethod' => 'GET', 'uri' => 'users/{username}/collection/folders/{folder_id}', 'parameters' => [ @@ -203,7 +429,7 @@ 'folder_id' => ['required' => true], ], ], - 'collection.folder.create' => [ + 'createCollectionFolder' => [ 'httpMethod' => 'POST', 'uri' => 'users/{username}/collection/folders', 'requiresAuth' => true, @@ -212,7 +438,7 @@ 'name' => ['required' => true], ], ], - 'collection.folder.edit' => [ + 'updateCollectionFolder' => [ 'httpMethod' => 'POST', 'uri' => 'users/{username}/collection/folders/{folder_id}', 'requiresAuth' => true, @@ -222,7 +448,7 @@ 'name' => ['required' => true], ], ], - 'collection.folder.delete' => [ + 'deleteCollectionFolder' => [ 'httpMethod' => 'DELETE', 'uri' => 'users/{username}/collection/folders/{folder_id}', 'requiresAuth' => true, @@ -231,7 +457,7 @@ 'folder_id' => ['required' => true], ], ], - 'collection.items' => [ + 'listCollectionItems' => [ 'httpMethod' => 'GET', 'uri' => 'users/{username}/collection/folders/{folder_id}/releases', 'parameters' => [ @@ -243,7 +469,7 @@ 'sort_order' => ['required' => false], ], ], - 'collection.items.by_release' => [ + 'getCollectionItemsByRelease' => [ 'httpMethod' => 'GET', 'uri' => 'users/{username}/collection/releases/{release_id}', 'parameters' => [ @@ -251,7 +477,7 @@ 'release_id' => ['required' => true], ], ], - 'collection.add_release' => [ + 'addToCollection' => [ 'httpMethod' => 'POST', 'uri' => 'users/{username}/collection/folders/{folder_id}/releases/{release_id}', 'requiresAuth' => true, @@ -261,7 +487,7 @@ 'release_id' => ['required' => true], ], ], - 'collection.edit_release' => [ + 'updateCollectionItem' => [ 'httpMethod' => 'POST', 'uri' => 'users/{username}/collection/folders/{folder_id}/releases/{release_id}/instances/{instance_id}', 'requiresAuth' => true, @@ -274,7 +500,7 @@ 'folder_id_new' => ['required' => false], ], ], - 'collection.remove_release' => [ + 'removeFromCollection' => [ 'httpMethod' => 'DELETE', 'uri' => 'users/{username}/collection/folders/{folder_id}/releases/{release_id}/instances/{instance_id}', 'requiresAuth' => true, @@ -285,14 +511,14 @@ 'instance_id' => ['required' => true], ], ], - 'collection.custom_fields' => [ + 'getCustomFields' => [ 'httpMethod' => 'GET', 'uri' => 'users/{username}/collection/fields', 'parameters' => [ 'username' => ['required' => true], ], ], - 'collection.edit_field' => [ + 'setCustomFields' => [ 'httpMethod' => 'POST', 'uri' => 'users/{username}/collection/folders/{folder_id}/releases/{release_id}/instances/{instance_id}/fields/{field_id}', 'requiresAuth' => true, @@ -305,7 +531,7 @@ 'value' => ['required' => true], ], ], - 'collection.value' => [ + 'getCollectionValue' => [ 'httpMethod' => 'GET', 'uri' => 'users/{username}/collection/value', 'requiresAuth' => true, @@ -315,9 +541,9 @@ ], // =========================== - // WANTLIST METHODS + // USER WANTLIST METHODS // =========================== - 'wantlist.get' => [ + 'getUserWantlist' => [ 'httpMethod' => 'GET', 'uri' => 'users/{username}/wants', 'parameters' => [ @@ -326,7 +552,7 @@ 'page' => ['required' => false], ], ], - 'wantlist.add' => [ + 'addToWantlist' => [ 'httpMethod' => 'PUT', 'uri' => 'users/{username}/wants/{release_id}', 'requiresAuth' => true, @@ -337,7 +563,7 @@ 'rating' => ['required' => false], ], ], - 'wantlist.edit' => [ + 'updateWantlistItem' => [ 'httpMethod' => 'POST', 'uri' => 'users/{username}/wants/{release_id}', 'requiresAuth' => true, @@ -348,7 +574,7 @@ 'rating' => ['required' => false], ], ], - 'wantlist.remove' => [ + 'removeFromWantlist' => [ 'httpMethod' => 'DELETE', 'uri' => 'users/{username}/wants/{release_id}', 'requiresAuth' => true, @@ -358,230 +584,10 @@ ], ], - // =========================== - // MARKETPLACE METHODS - // =========================== - 'inventory.get' => [ - 'httpMethod' => 'GET', - 'uri' => 'users/{username}/inventory', - 'parameters' => [ - 'username' => ['required' => true], - 'status' => ['required' => false], - 'sort' => ['required' => false], - 'sort_order' => ['required' => false], - 'per_page' => ['required' => false], - 'page' => ['required' => false], - ], - ], - 'listing.get' => [ - 'httpMethod' => 'GET', - 'uri' => 'marketplace/listings/{listing_id}', - 'parameters' => [ - 'listing_id' => ['required' => true], - 'curr_abbr' => ['required' => false], - ], - ], - 'listing.create' => [ - 'httpMethod' => 'POST', - 'uri' => 'marketplace/listings', - 'requiresAuth' => true, - 'parameters' => [ - 'release_id' => ['required' => true], - 'condition' => ['required' => true], - 'sleeve_condition' => ['required' => false], - 'price' => ['required' => true], - 'comments' => ['required' => false], - 'allow_offers' => ['required' => false], - 'status' => ['required' => true], - 'external_id' => ['required' => false], - 'location' => ['required' => false], - 'weight' => ['required' => false], - 'format_quantity' => ['required' => false], - ], - ], - 'listing.update' => [ - 'httpMethod' => 'POST', - 'uri' => 'marketplace/listings/{listing_id}', - 'requiresAuth' => true, - 'parameters' => [ - 'listing_id' => ['required' => true], - 'condition' => ['required' => false], - 'sleeve_condition' => ['required' => false], - 'price' => ['required' => false], - 'comments' => ['required' => false], - 'allow_offers' => ['required' => false], - 'status' => ['required' => false], - 'external_id' => ['required' => false], - 'location' => ['required' => false], - 'weight' => ['required' => false], - 'format_quantity' => ['required' => false], - 'curr_abbr' => ['required' => false], - ], - ], - 'listing.delete' => [ - 'httpMethod' => 'DELETE', - 'uri' => 'marketplace/listings/{listing_id}', - 'requiresAuth' => true, - 'parameters' => [ - 'listing_id' => ['required' => true], - ], - ], - 'marketplace.fee' => [ - 'httpMethod' => 'GET', - 'uri' => 'marketplace/fee/{price}', - 'parameters' => [ - 'price' => ['required' => true], - ], - ], - 'marketplace.fee_currency' => [ - 'httpMethod' => 'GET', - 'uri' => 'marketplace/fee/{price}/{currency}', - 'parameters' => [ - 'price' => ['required' => true], - 'currency' => ['required' => true], - ], - ], - 'marketplace.price_suggestions' => [ - 'httpMethod' => 'GET', - 'uri' => 'marketplace/price_suggestions/{release_id}', - 'requiresAuth' => true, - 'parameters' => [ - 'release_id' => ['required' => true], - ], - ], - 'marketplace.stats' => [ - 'httpMethod' => 'GET', - 'uri' => 'marketplace/stats/{release_id}', - 'parameters' => [ - 'release_id' => ['required' => true], - 'curr_abbr' => ['required' => false], - ], - ], - 'order.get' => [ - 'httpMethod' => 'GET', - 'uri' => 'marketplace/orders/{order_id}', - 'requiresAuth' => true, - 'parameters' => [ - 'order_id' => ['required' => true], - ], - ], - 'orders.get' => [ - 'httpMethod' => 'GET', - 'uri' => 'marketplace/orders', - 'requiresAuth' => true, - 'parameters' => [ - 'status' => ['required' => false], - 'sort' => ['required' => false], - 'sort_order' => ['required' => false], - 'created_before' => ['required' => false], - 'created_after' => ['required' => false], - 'archived' => ['required' => false], - ], - ], - 'order.update' => [ - 'httpMethod' => 'POST', - 'uri' => 'marketplace/orders/{order_id}', - 'requiresAuth' => true, - 'parameters' => [ - 'order_id' => ['required' => true], - 'status' => ['required' => false], - 'shipping' => ['required' => false], - ], - ], - 'order.messages' => [ - 'httpMethod' => 'GET', - 'uri' => 'marketplace/orders/{order_id}/messages', - 'requiresAuth' => true, - 'parameters' => [ - 'order_id' => ['required' => true], - ], - ], - 'order.message.add' => [ - 'httpMethod' => 'POST', - 'uri' => 'marketplace/orders/{order_id}/messages', - 'requiresAuth' => true, - 'parameters' => [ - 'order_id' => ['required' => true], - 'message' => ['required' => false], - 'status' => ['required' => false], - ], - ], - - // =========================== - // INVENTORY EXPORT METHODS - // =========================== - 'inventory.export.create' => [ - 'httpMethod' => 'POST', - 'uri' => 'inventory/export', - 'requiresAuth' => true, - ], - 'inventory.export.list' => [ - 'httpMethod' => 'GET', - 'uri' => 'inventory/export', - 'requiresAuth' => true, - ], - 'inventory.export.get' => [ - 'httpMethod' => 'GET', - 'uri' => 'inventory/export/{export_id}', - 'requiresAuth' => true, - 'parameters' => [ - 'export_id' => ['required' => true], - ], - ], - 'inventory.export.download' => [ - 'httpMethod' => 'GET', - 'uri' => 'inventory/export/{export_id}/download', - 'requiresAuth' => true, - 'parameters' => [ - 'export_id' => ['required' => true], - ], - ], - - // =========================== - // INVENTORY UPLOAD METHODS - // =========================== - 'inventory.upload.add' => [ - 'httpMethod' => 'POST', - 'uri' => 'inventory/upload/add', - 'requiresAuth' => true, - 'parameters' => [ - 'upload' => ['required' => true], - ], - ], - 'inventory.upload.change' => [ - 'httpMethod' => 'POST', - 'uri' => 'inventory/upload/change', - 'requiresAuth' => true, - 'parameters' => [ - 'upload' => ['required' => true], - ], - ], - 'inventory.upload.delete' => [ - 'httpMethod' => 'POST', - 'uri' => 'inventory/upload/delete', - 'requiresAuth' => true, - 'parameters' => [ - 'upload' => ['required' => true], - ], - ], - 'inventory.upload.list' => [ - 'httpMethod' => 'GET', - 'uri' => 'inventory/upload', - 'requiresAuth' => true, - ], - 'inventory.upload.get' => [ - 'httpMethod' => 'GET', - 'uri' => 'inventory/upload/{upload_id}', - 'requiresAuth' => true, - 'parameters' => [ - 'upload_id' => ['required' => true], - ], - ], - // =========================== // USER LISTS METHODS // =========================== - 'user.lists' => [ + 'getUserLists' => [ 'httpMethod' => 'GET', 'uri' => 'users/{username}/lists', 'parameters' => [ @@ -590,7 +596,7 @@ 'page' => ['required' => false], ], ], - 'list.get' => [ + 'getUserList' => [ 'httpMethod' => 'GET', 'uri' => 'lists/{list_id}', 'parameters' => [ @@ -604,7 +610,7 @@ 'base_uri' => 'https://api.discogs.com/', 'timeout' => 30, 'headers' => [ - 'User-Agent' => 'DiscogsClient/3.0 (+https://github.com/calliostro/php-discogs-api)', + 'User-Agent' => 'DiscogsClient/4.0.0 +https://github.com/calliostro/php-discogs-api', 'Accept' => 'application/json', ], ], diff --git a/src/ClientFactory.php b/src/ClientFactory.php deleted file mode 100644 index ad4ed1d..0000000 --- a/src/ClientFactory.php +++ /dev/null @@ -1,75 +0,0 @@ - $options - */ - public static function create(string $userAgent = 'DiscogsClient/3.0 (+https://github.com/calliostro/php-discogs-api)', array $options = []): DiscogsApiClient - { - $defaultOptions = [ - 'base_uri' => 'https://api.discogs.com/', - 'timeout' => 30, - 'headers' => [ - 'User-Agent' => $userAgent, - 'Accept' => 'application/json', - ], - ]; - - $guzzleClient = new Client(array_merge($defaultOptions, $options)); - - return new DiscogsApiClient($guzzleClient); - } - - /** - * Create a Discogs API client with OAuth authentication - * - * @param array $options - */ - public static function createWithOAuth(string $token, string $tokenSecret, string $userAgent = 'DiscogsClient/3.0 (+https://github.com/calliostro/php-discogs-api)', array $options = []): DiscogsApiClient - { - $defaultOptions = [ - 'base_uri' => 'https://api.discogs.com/', - 'timeout' => 30, - 'headers' => [ - 'User-Agent' => $userAgent, - 'Accept' => 'application/json', - 'Authorization' => sprintf('OAuth oauth_token="%s", oauth_token_secret="%s"', $token, $tokenSecret), - ], - ]; - - $guzzleClient = new Client(array_merge($defaultOptions, $options)); - - return new DiscogsApiClient($guzzleClient); - } - - /** - * Create a Discogs API client with personal access token authentication - * - * @param array $options - */ - public static function createWithToken(string $token, string $userAgent = 'DiscogsClient/3.0 (+https://github.com/calliostro/php-discogs-api)', array $options = []): DiscogsApiClient - { - $defaultOptions = [ - 'base_uri' => 'https://api.discogs.com/', - 'timeout' => 30, - 'headers' => [ - 'User-Agent' => $userAgent, - 'Accept' => 'application/json', - 'Authorization' => sprintf('Discogs token=%s', $token), - ], - ]; - - $guzzleClient = new Client(array_merge($defaultOptions, $options)); - - return new DiscogsApiClient($guzzleClient); - } -} diff --git a/src/ConfigCache.php b/src/ConfigCache.php new file mode 100644 index 0000000..fe17290 --- /dev/null +++ b/src/ConfigCache.php @@ -0,0 +1,44 @@ +|null Cached service configuration */ + private static ?array $config = null; + + /** + * Private constructor to prevent instantiation + * @codeCoverageIgnore + */ + private function __construct() + { + // Empty constructor to prevent instantiation + } + + /** + * Get cached service configuration with lazy loading + * @return array + */ + public static function get(): array + { + if (self::$config === null) { + self::$config = require __DIR__ . '/../resources/service.php'; + } + return self::$config; + } + + /** + * Clear cache (mainly for testing) + */ + public static function clear(): void + { + self::$config = null; + } +} diff --git a/src/DiscogsApiClient.php b/src/DiscogsApiClient.php deleted file mode 100644 index ec00bdd..0000000 --- a/src/DiscogsApiClient.php +++ /dev/null @@ -1,202 +0,0 @@ - artistGet(array $params = []) Get artist information โ€” https://www.discogs.com/developers/#page:database,header:database-artist - * @method array artistReleases(array $params = []) Get artist releases โ€” https://www.discogs.com/developers/#page:database,header:database-artist-releases - * @method array releaseGet(array $params = []) Get release information โ€” https://www.discogs.com/developers/#page:database,header:database-release - * @method array releaseRatingGet(array $params = []) Get release rating โ€” https://www.discogs.com/developers/#page:database,header:database-release-rating-by-user - * @method array releaseRatingPut(array $params = []) Set release rating โ€” https://www.discogs.com/developers/#page:database,header:database-release-rating-by-user-post - * @method array releaseRatingDelete(array $params = []) Delete release rating โ€” https://www.discogs.com/developers/#page:database,header:database-release-rating-by-user-delete - * @method array releaseRatingCommunity(array $params = []) Get community release rating โ€” https://www.discogs.com/developers/#page:database,header:database-release-rating-community - * @method array releaseStats(array $params = []) Get release statistics โ€” https://www.discogs.com/developers/#page:database,header:database-release-stats - * @method array masterGet(array $params = []) Get master release information โ€” https://www.discogs.com/developers/#page:database,header:database-master-release - * @method array masterVersions(array $params = []) Get master release versions โ€” https://www.discogs.com/developers/#page:database,header:database-master-release-versions - * @method array labelGet(array $params = []) Get label information โ€” https://www.discogs.com/developers/#page:database,header:database-label - * @method array labelReleases(array $params = []) Get label releases โ€” https://www.discogs.com/developers/#page:database,header:database-label-releases - * @method array search(array $params = []) Search database โ€” https://www.discogs.com/developers/#page:database,header:database-search - * - * User Identity methods: - * @method array identityGet(array $params = []) Get user identity (OAuth required) โ€” https://www.discogs.com/developers/#page:user-identity - * @method array userGet(array $params = []) Get user profile โ€” https://www.discogs.com/developers/#page:user-identity,header:user-identity-profile - * @method array userEdit(array $params = []) Edit user profile โ€” https://www.discogs.com/developers/#page:user-identity,header:user-identity-profile-post - * @method array userSubmissions(array $params = []) Get user submissions โ€” https://www.discogs.com/developers/#page:user-identity,header:user-identity-user-submissions - * @method array userContributions(array $params = []) Get user contributions โ€” https://www.discogs.com/developers/#page:user-identity,header:user-identity-user-contributions - * @method array userLists(array $params = []) Get user lists โ€” https://www.discogs.com/developers/#page:user-lists - * - * Collection methods: - * @method array collectionFolders(array $params = []) Get collection folders โ€” https://www.discogs.com/developers/#page:user-collection - * @method array collectionFolderGet(array $params = []) Get a collection folder โ€” https://www.discogs.com/developers/#page:user-collection,header:user-collection-collection-folder - * @method array collectionFolderCreate(array $params = []) Create a collection folder (OAuth required) โ€” https://www.discogs.com/developers/#page:user-collection,header:user-collection-create-folder - * @method array collectionFolderEdit(array $params = []) Edit collection folder (OAuth required) โ€” https://www.discogs.com/developers/#page:user-collection,header:user-collection-edit-folder - * @method array collectionFolderDelete(array $params = []) Delete the collection folder (OAuth required) โ€” https://www.discogs.com/developers/#page:user-collection,header:user-collection-delete-folder - * @method array collectionItems(array $params = []) Get collection items by folder โ€” https://www.discogs.com/developers/#page:user-collection,header:user-collection-collection-items-by-folder - * @method array collectionItemsByRelease(array $params = []) Get collection instances by release โ€” https://www.discogs.com/developers/#page:user-collection,header:user-collection-collection-items-by-release - * @method array collectionAddRelease(array $params = []) Add release to collection (OAuth required) โ€” https://www.discogs.com/developers/#page:user-collection,header:user-collection-add-to-collection-folder - * @method array collectionEditRelease(array $params = []) Edit release in collection (OAuth required) โ€” https://www.discogs.com/developers/#page:user-collection,header:user-collection-change-rating-of-release - * @method array collectionRemoveRelease(array $params = []) Remove release from collection (OAuth required) โ€” https://www.discogs.com/developers/#page:user-collection,header:user-collection-delete-instance-from-folder - * @method array collectionCustomFields(array $params = []) Get collection custom fields โ€” https://www.discogs.com/developers/#page:user-collection,header:user-collection-list-custom-fields - * @method array collectionEditField(array $params = []) Edit collection custom field (OAuth required) โ€” https://www.discogs.com/developers/#page:user-collection,header:user-collection-edit-fields-instance - * @method array collectionValue(array $params = []) Get collection value (OAuth required) โ€” https://www.discogs.com/developers/#page:user-collection,header:user-collection-collection-value - * - * Wantlist methods: - * @method array wantlistGet(array $params = []) Get user wantlist โ€” https://www.discogs.com/developers/#page:user-wantlist - * @method array wantlistAdd(array $params = []) Add release to wantlist (OAuth required) โ€” https://www.discogs.com/developers/#page:user-wantlist,header:user-wantlist-add-to-wantlist - * @method array wantlistEdit(array $params = []) Edit wantlist release (OAuth required) โ€” https://www.discogs.com/developers/#page:user-wantlist,header:user-wantlist-edit-notes - * @method array wantlistRemove(array $params = []) Remove release from wantlist (OAuth required) โ€” https://www.discogs.com/developers/#page:user-wantlist,header:user-wantlist-delete-from-wantlist - * - * Marketplace methods: - * @method array inventoryGet(array $params = []) Get user inventory โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-inventory - * @method array listingGet(array $params = []) Get marketplace listing โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-listing - * @method array listingCreate(array $params = []) Create marketplace listing (OAuth required) โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-new-listing - * @method array listingUpdate(array $params = []) Update marketplace listing (OAuth required) โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-listing - * @method array listingDelete(array $params = []) Delete marketplace listing (OAuth required) โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-listing-delete - * @method array marketplaceFee(array $params = []) Calculate marketplace fee โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-fee - * @method array marketplaceFeeCurrency(array $params = []) Calculate marketplace fee with currency โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-fee-with-currency - * @method array marketplacePriceSuggestions(array $params = []) Get marketplace price suggestions (OAuth required) โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-price-suggestions - * @method array marketplaceStats(array $params = []) Get marketplace statistics โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-stats - * @method array orderGet(array $params = []) Get order details (OAuth required) โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-order - * @method array ordersGet(array $params = []) Get orders (OAuth required) โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-list-orders - * @method array orderUpdate(array $params = []) Update order (OAuth required) โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-order-post - * @method array orderMessages(array $params = []) Get order messages (OAuth required) โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-list-order-messages - * @method array orderMessageAdd(array $params = []) Add an order message (OAuth required) โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-list-order-messages-post - * - * Inventory Export methods: - * @method array inventoryExportCreate(array $params = []) Create inventory export (OAuth required) โ€” https://www.discogs.com/developers/#page:inventory-export - * @method array inventoryExportList(array $params = []) List inventory exports (OAuth required) โ€” https://www.discogs.com/developers/#page:inventory-export - * @method array inventoryExportGet(array $params = []) Get inventory export (OAuth required) โ€” https://www.discogs.com/developers/#page:inventory-export - * @method array inventoryExportDownload(array $params = []) Download inventory export (OAuth required) โ€” https://www.discogs.com/developers/#page:inventory-export - * - * Inventory Upload methods: - * @method array inventoryUploadAdd(array $params = []) Add inventory upload (OAuth required) โ€” https://www.discogs.com/developers/#page:inventory-upload - * @method array inventoryUploadChange(array $params = []) Change inventory upload (OAuth required) โ€” https://www.discogs.com/developers/#page:inventory-upload - * @method array inventoryUploadDelete(array $params = []) Delete inventory upload (OAuth required) โ€” https://www.discogs.com/developers/#page:inventory-upload - * @method array inventoryUploadList(array $params = []) List inventory uploads (OAuth required) โ€” https://www.discogs.com/developers/#page:inventory-upload - * @method array inventoryUploadGet(array $params = []) Get inventory upload (OAuth required) โ€” https://www.discogs.com/developers/#page:inventory-upload - * - * List methods: - * @method array listGet(array $params = []) Get list โ€” https://www.discogs.com/developers/#page:user-lists - */ -final class DiscogsApiClient -{ - private GuzzleClient $client; - - /** @var array */ - private array $config; - - public function __construct(GuzzleClient $client) - { - $this->client = $client; - - // Load service configuration - $this->config = require __DIR__ . '/../resources/service.php'; - } - - /** - * Magic method to call Discogs API operations - * - * Examples: - * - artistGet(['id' => '108713']) - * - search(['q' => 'Nirvana', 'type' => 'artist']) - * - releaseGet(['id' => '249504']) - * - * @param array $arguments - * @return array - */ - public function __call(string $method, array $arguments): array - { - $params = is_array($arguments[0] ?? null) ? $arguments[0] : []; - - return $this->callOperation($method, $params); - } - - /** - * @param array $params - * @return array - */ - private function callOperation(string $method, array $params): array - { - $operationName = $this->convertMethodToOperation($method); - - if (!isset($this->config['operations'][$operationName])) { - throw new \RuntimeException("Unknown operation: $operationName"); - } - - $operation = $this->config['operations'][$operationName]; - - try { - $httpMethod = $operation['httpMethod'] ?? 'GET'; - $uri = $this->buildUri($operation['uri'] ?? '', $params); - - if ($httpMethod === 'POST') { - $response = $this->client->post($uri, ['json' => $params]); - } elseif ($httpMethod === 'PUT') { - $response = $this->client->put($uri, ['json' => $params]); - } elseif ($httpMethod === 'DELETE') { - $response = $this->client->delete($uri, ['query' => $params]); - } else { - $response = $this->client->get($uri, ['query' => $params]); - } - - $body = $response->getBody()->getContents(); - $data = json_decode($body, true); - - if (json_last_error() !== JSON_ERROR_NONE) { - throw new \RuntimeException('Invalid JSON response: ' . json_last_error_msg()); - } - - if (!is_array($data)) { - throw new \RuntimeException('Expected array response from API'); - } - - if (isset($data['error'])) { - throw new \RuntimeException($data['message'] ?? 'API Error', $data['error']); - } - - return $data; - } catch (GuzzleException $e) { - throw new \RuntimeException('HTTP request failed: ' . $e->getMessage(), 0, $e); - } - } - - /** - * Convert method name to operation name - * artistGet -> artist.get - * orderMessages -> order.messages - */ - private function convertMethodToOperation(string $method): string - { - // Split a camelCase into parts - $parts = preg_split('/(?=[A-Z])/', $method, -1, PREG_SPLIT_NO_EMPTY) ?: []; - - if (!$parts) { - return $method; - } - - // Convert to dot notation - return strtolower(implode('.', $parts)); - } - - /** - * Build URI with path parameters - * - * @param array $params - */ - private function buildUri(string $uri, array $params): string - { - foreach ($params as $key => $value) { - $uri = str_replace('{' . $key . '}', (string) $value, $uri); - } - - return ltrim($uri, '/'); - } -} diff --git a/src/DiscogsClient.php b/src/DiscogsClient.php new file mode 100644 index 0000000..bf23a73 --- /dev/null +++ b/src/DiscogsClient.php @@ -0,0 +1,497 @@ + getArtist(int|string $artistId) Get artist information โ€” https://www.discogs.com/developers/#page:database,header:database-artist + * @method array listArtistReleases(int|string $artistId, ?string $sort = null, ?string $sortOrder = null, ?int $perPage = null, ?int $page = null) Get artist releases โ€” https://www.discogs.com/developers/#page:database,header:database-artist-releases + * @method array getRelease(int|string $releaseId, ?string $currAbbr = null) Get release information โ€” https://www.discogs.com/developers/#page:database,header:database-release + * @method array getUserReleaseRating(int|string $releaseId, string $username) Get user's release rating โ€” https://www.discogs.com/developers/#page:database,header:database-release-rating-by-user + * @method array updateUserReleaseRating(int|string $releaseId, string $username, int $rating) Set release rating (OAuth required) โ€” https://www.discogs.com/developers/#page:database,header:database-release-rating-by-user-post + * @method array deleteUserReleaseRating(int|string $releaseId, string $username) Delete release rating (OAuth required) โ€” https://www.discogs.com/developers/#page:database,header:database-release-rating-by-user-delete + * @method array getCommunityReleaseRating(int|string $releaseId) Get community release rating โ€” https://www.discogs.com/developers/#page:database,header:database-release-rating-community + * @method array getReleaseStats(int|string $releaseId) Get release statistics โ€” https://www.discogs.com/developers/#page:database,header:database-release-stats + * @method array getMaster(int|string $masterId) Get master release information โ€” https://www.discogs.com/developers/#page:database,header:database-master-release + * @method array listMasterVersions(int|string $masterId, ?int $perPage = null, ?int $page = null, ?string $format = null, ?string $label = null, ?string $released = null, ?string $country = null, ?string $sort = null, ?string $sortOrder = null) Get master release versions โ€” https://www.discogs.com/developers/#page:database,header:database-master-release-versions + * @method array getLabel(int|string $labelId) Get label information โ€” https://www.discogs.com/developers/#page:database,header:database-label + * @method array listLabelReleases(int|string $labelId, ?int $perPage = null, ?int $page = null) Get label releases โ€” https://www.discogs.com/developers/#page:database,header:database-label-releases + * @method array search(?string $q = null, ?string $type = null, ?string $title = null, ?string $releaseTitle = null, ?string $credit = null, ?string $artist = null, ?string $anv = null, ?string $label = null, ?string $genre = null, ?string $style = null, ?string $country = null, ?string $year = null, ?string $format = null, ?string $catno = null, ?string $barcode = null, ?string $track = null, ?string $submitter = null, ?string $contributor = null, ?int $perPage = null, ?int $page = null) Search database โ€” https://www.discogs.com/developers/#page:database,header:database-search + * + * User Identity methods: + * @method array getIdentity() Get user identity (OAuth required) โ€” https://www.discogs.com/developers/#page:user-identity + * @method array getUser(string $username) Get user profile โ€” https://www.discogs.com/developers/#page:user-identity,header:user-identity-profile + * @method array updateUser(string $username, ?string $name = null, ?string $homePage = null, ?string $location = null, ?string $profile = null, ?string $currAbbr = null) Edit user profile (OAuth required) โ€” https://www.discogs.com/developers/#page:user-identity,header:user-identity-profile-post + * @method array listUserSubmissions(string $username, ?int $perPage = null, ?int $page = null) Get user submissions โ€” https://www.discogs.com/developers/#page:user-identity,header:user-identity-user-submissions + * @method array listUserContributions(string $username, ?int $perPage = null, ?int $page = null) Get user contributions โ€” https://www.discogs.com/developers/#page:user-identity,header:user-identity-user-contributions + * + * User Collection methods: + * @method array listCollectionFolders(string $username) Get collection folders โ€” https://www.discogs.com/developers/#page:user-collection + * @method array getCollectionFolder(string $username, int|string $folderId) Get a collection folder โ€” https://www.discogs.com/developers/#page:user-collection,header:user-collection-collection-folder + * @method array createCollectionFolder(string $username, string $name) Create a collection folder (OAuth required) โ€” https://www.discogs.com/developers/#page:user-collection,header:user-collection-create-folder + * @method array updateCollectionFolder(string $username, int|string $folderId, string $name) Edit collection folder (OAuth required) โ€” https://www.discogs.com/developers/#page:user-collection,header:user-collection-edit-folder + * @method array deleteCollectionFolder(string $username, int|string $folderId) Delete the collection folder (OAuth required) โ€” https://www.discogs.com/developers/#page:user-collection,header:user-collection-delete-folder + * @method array listCollectionItems(string $username, int|string $folderId, ?int $perPage = null, ?int $page = null, ?string $sort = null, ?string $sortOrder = null) Get collection items by folder โ€” https://www.discogs.com/developers/#page:user-collection,header:user-collection-collection-items-by-folder + * @method array getCollectionItemsByRelease(string $username, int|string $releaseId) Get collection instances by release โ€” https://www.discogs.com/developers/#page:user-collection,header:user-collection-collection-items-by-release + * @method array addToCollection(string $username, int|string $folderId, int|string $releaseId) Add release to a collection (OAuth required) โ€” https://www.discogs.com/developers/#page:user-collection,header:user-collection-add-to-collection-folder + * @method array updateCollectionItem(string $username, int|string $folderId, int|string $releaseId, int|string $instanceId, ?int $rating = null, int|string|null $folderIdNew = null) Edit release in a collection (OAuth required) โ€” https://www.discogs.com/developers/#page:user-collection,header:user-collection-change-rating-of-release + * @method array removeFromCollection(string $username, int|string $folderId, int|string $releaseId, int|string $instanceId) Remove release from a collection (OAuth required) โ€” https://www.discogs.com/developers/#page:user-collection,header:user-collection-delete-instance-from-folder + * @method array getCustomFields(string $username) Get collection custom fields โ€” https://www.discogs.com/developers/#page:user-collection,header:user-collection-list-custom-fields + * @method array setCustomFields(string $username, int|string $folderId, int|string $releaseId, int|string $instanceId, int|string $fieldId, string $value) Edit collection custom field (OAuth required) โ€” https://www.discogs.com/developers/#page:user-collection,header:user-collection-edit-fields-instance + * @method array getCollectionValue(string $username) Get collection value (OAuth required) โ€” https://www.discogs.com/developers/#page:user-collection,header:user-collection-collection-value + * + * User Wantlist methods: + * @method array getUserWantlist(string $username, ?int $perPage = null, ?int $page = null) Get wantlist โ€” https://www.discogs.com/developers/#page:user-wantlist + * @method array addToWantlist(string $username, int|string $releaseId, ?string $notes = null, ?int $rating = null) Add release to wantlist (OAuth required) โ€” https://www.discogs.com/developers/#page:user-wantlist,header:user-wantlist-add-to-wantlist + * @method array updateWantlistItem(string $username, int|string $releaseId, ?string $notes = null, ?int $rating = null) Edit wantlist entry (OAuth required) โ€” https://www.discogs.com/developers/#page:user-wantlist,header:user-wantlist-edit-notes + * @method array removeFromWantlist(string $username, int|string $releaseId) Remove release from wantlist (OAuth required) โ€” https://www.discogs.com/developers/#page:user-wantlist,header:user-wantlist-delete-from-wantlist + * + * Marketplace methods: + * @method array getUserInventory(string $username, ?string $status = null, ?string $sort = null, ?string $sortOrder = null, ?int $perPage = null, ?int $page = null) Get user's marketplace inventory โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-inventory + * @method array getMarketplaceListing(int $listingId, ?string $currAbbr = null) Get marketplace listing โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-listing + * @method array createMarketplaceListing(int|string $releaseId, string $condition, float $price, string $status, ?string $sleeveCondition = null, ?string $comments = null, ?bool $allowOffers = null, ?string $externalId = null, ?string $location = null, ?float $weight = null, ?int $formatQuantity = null) Create marketplace listing (OAuth required) โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-new-listing + * @method array updateMarketplaceListing(int|string $listingId, ?string $condition = null, ?string $sleeveCondition = null, ?float $price = null, ?string $comments = null, ?bool $allowOffers = null, ?string $status = null, ?string $externalId = null, ?string $location = null, ?float $weight = null, ?int $formatQuantity = null, ?string $currAbbr = null) Edit marketplace listing (OAuth required) โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-listing + * @method array deleteMarketplaceListing(int|string $listingId) Delete marketplace listing (OAuth required) โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-listing-delete + * @method array getMarketplaceFee(float $price) Get marketplace fee (SELLER ACCOUNT required) โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-fee + * @method array getMarketplaceFeeByCurrency(float $price, string $currency) Get marketplace fee with currency (SELLER ACCOUNT required) โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-fee-with-currency + * @method array getMarketplacePriceSuggestions(int|string $releaseId) Get price suggestions (SELLER ACCOUNT required) โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-price-suggestions + * @method array getMarketplaceStats(int|string $releaseId, ?string $currAbbr = null) Get marketplace release statistics โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-stats + * @method array getMarketplaceOrder(int|string $orderId) Get order (OAuth required) โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-order + * @method array getMarketplaceOrders(?string $status = null, ?string $sort = null, ?string $sortOrder = null, ?string $createdBefore = null, ?string $createdAfter = null, ?bool $archived = null) List orders (OAuth required) โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-list-orders + * @method array updateMarketplaceOrder(int|string $orderId, ?string $status = null, ?float $shipping = null) Edit order (OAuth required) โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-order-post + * @method array getMarketplaceOrderMessages(int|string $orderId) List order messages (OAuth required) โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-list-order-messages + * @method array addMarketplaceOrderMessage(int|string $orderId, ?string $message = null, ?string $status = null) Add an order message (OAuth required) โ€” https://www.discogs.com/developers/#page:marketplace,header:marketplace-list-order-messages-post + * + * Inventory Export methods: + * @method array createInventoryExport() Create inventory export (OAuth required) โ€” https://www.discogs.com/developers/#page:inventory-export + * @method array listInventoryExports() List inventory exports (OAuth required) โ€” https://www.discogs.com/developers/#page:inventory-export + * @method array getInventoryExport(int|string $exportId) Get inventory export (OAuth required) โ€” https://www.discogs.com/developers/#page:inventory-export + * @method array downloadInventoryExport(int|string $exportId) Download inventory export (OAuth required) โ€” https://www.discogs.com/developers/#page:inventory-export + * + * Inventory Upload methods: + * @method array addInventoryUpload(string $upload) Add inventory upload (OAuth required) โ€” https://www.discogs.com/developers/#page:inventory-upload + * @method array changeInventoryUpload(string $upload) Change inventory upload (OAuth required) โ€” https://www.discogs.com/developers/#page:inventory-upload + * @method array deleteInventoryUpload(string $upload) Delete inventory upload (OAuth required) โ€” https://www.discogs.com/developers/#page:inventory-upload + * @method array listInventoryUploads() List inventory uploads (OAuth required) โ€” https://www.discogs.com/developers/#page:inventory-upload + * @method array getInventoryUpload(int|string $uploadId) Get inventory upload (OAuth required) โ€” https://www.discogs.com/developers/#page:inventory-upload + * + * User Lists methods: + * @method array getUserLists(string $username, ?int $perPage = null, ?int $page = null) Get user lists โ€” https://www.discogs.com/developers/#page:user-lists + * @method array getUserList(int|string $listId) Get user list โ€” https://www.discogs.com/developers/#page:user-lists + */ +final class DiscogsClient +{ + // Performance constants for validation limits + private const MAX_URI_LENGTH = 2048; + private const MAX_PLACEHOLDERS = 50; + private const PARAM_NAME_PATTERN = '/^[a-zA-Z][a-zA-Z0-9_]*$/'; + private const PLACEHOLDER_PATTERN = '/\{([a-zA-Z][a-zA-Z0-9_]*)}/u'; + + private GuzzleClient $client; + + /** @var array */ + private array $config; + + /** + * @param array|GuzzleClient $optionsOrClient + */ + public function __construct(array|GuzzleClient $optionsOrClient = []) + { + // Load service configuration (cached for performance) + $this->config = ConfigCache::get(); + + // Create or use the provided Guzzle client + if ($optionsOrClient instanceof GuzzleClient) { + $this->client = $optionsOrClient; + } else { + $clientOptions = array_merge([ + 'base_uri' => $this->config['baseUrl'], + 'headers' => [ + 'User-Agent' => $this->config['client']['options']['headers']['User-Agent'] + ] + ], $optionsOrClient); + $this->client = new GuzzleClient($clientOptions); + } + } + + /** + * Magic method to call Discogs API operations with intelligent parameter mapping + * + * Examples: + * - getArtist(139250) // Maps to ['artist_id' => 139250] + * - search('Billie Eilish', 'artist') // Maps to ['q' => 'Billie Eilish', 'type' => 'artist'] + * - listArtistReleases(139250, 'year', 'desc', 50, 1) // All positional parameters + * - addToCollection('username', 1, 12345) // username, folder_id, release_id + * + * @param array $arguments + * @return array + * @throws RuntimeException If API operation fails or returns invalid data + * @throws InvalidArgumentException If method parameters are invalid + * @throws GuzzleException If HTTP request fails + */ + public function __call(string $method, array $arguments): array + { + $params = $this->buildParamsFromArguments($method, $arguments); + return $this->callOperation($method, $params); + } + + /** + * Build parameters from positional/named arguments with intelligent mapping + * + * @param array $arguments + * @return array + */ + private function buildParamsFromArguments(string $method, array $arguments): array + { + if (empty($arguments)) { + return []; + } + + $operationName = $this->convertMethodToOperation($method); + + if (!isset($this->config['operations'][$operationName]['parameters'])) { + return []; + } + + $parameterNames = array_keys($this->config['operations'][$operationName]['parameters']); + $params = []; + + // Check if we have named parameters (associative array with string keys) + $hasNamedParams = !array_is_list($arguments); + + if ($hasNamedParams) { + // Handle named parameters - only camelCase from PHPDoc allowed + $allowedCamelParams = $this->getAllowedCamelCaseParams($operationName); + + foreach ($arguments as $key => $value) { + if (is_string($key)) { + // Only allow camelCase parameters from PHPDoc + if (in_array($key, $allowedCamelParams, true)) { + // Convert to snake_case for internal use + $snakeKey = $this->convertCamelToSnake($key); + $params[$snakeKey] = $value; + } else { + // PHP-native behavior: throw Error for unknown named parameters + throw new \Error("Unknown named parameter \$$key"); + } + } + } + } else { + // Handle positional parameters - only map up to available parameter count + $maxParams = count($parameterNames); + foreach ($arguments as $index => $value) { + if ($index < $maxParams && isset($parameterNames[$index])) { + $params[$parameterNames[$index]] = $value; + } + } + } + + // Validate required parameters and null values + if ($hasNamedParams) { + $this->validateRequiredParameters($operationName, $params, $arguments); + } + + return $params; + } + + /** + * Convert method name to operation name + * In v4.0, we use camelCase directly, no conversion needed + */ + private function convertMethodToOperation(string $method): string + { + // v4.0: Direct mapping, no conversion + return $method; + } + + /** + * Get allowed camelCase parameters from PHPDoc for operation + * + * @return array + */ + private function getAllowedCamelCaseParams(string $operationName): array + { + // Map snake_case internal parameters to camelCase PHPDoc parameters + if (!isset($this->config['operations'][$operationName]['parameters'])) { + return []; + } + + $snakeParams = array_keys($this->config['operations'][$operationName]['parameters']); + $camelParams = []; + + foreach ($snakeParams as $snakeParam) { + if (is_string($snakeParam)) { + $camelParams[] = $this->convertSnakeToCamel($snakeParam); + } + } + + return $camelParams; + } + + /** + * Convert snake_case parameter names to camelCase + * Optimized for performance with early returns + */ + private function convertSnakeToCamel(string $snakeCase): string + { + // Fast path for strings without underscores + if (!str_contains($snakeCase, '_')) { + return $snakeCase; + } + + return lcfirst(str_replace('_', '', ucwords($snakeCase, '_'))); + } + + /** + * Convert camelCase parameter names to snake_case + * Optimized for performance with early returns + */ + private function convertCamelToSnake(string $camelCase): string + { + // Fast path for empty strings or already snake_case + if ($camelCase === '' || !preg_match('/[A-Z]/', $camelCase)) { + return $camelCase; + } + + $result = preg_replace('/([a-z])([A-Z])/', '$1_$2', $camelCase); + return strtolower($result ?? $camelCase); + } + + /** + * Validate required parameters and null values + * + * @param array $params + * @param array $originalNamedArgs + */ + private function validateRequiredParameters(string $operationName, array $params, array $originalNamedArgs): void + { + if (!isset($this->config['operations'][$operationName]['parameters'])) { + return; + } + + $parameterConfig = $this->config['operations'][$operationName]['parameters']; + + // Check for missing required parameters + foreach ($parameterConfig as $paramName => $paramConfig) { + if (($paramConfig['required'] ?? false) && !array_key_exists($paramName, $params)) { + // Convert snake_case to camelCase for user-friendly error message + $camelName = $this->convertSnakeToCamel($paramName); + throw new \InvalidArgumentException("Required parameter $camelName is missing"); + } + } + + // Check for required parameters with null values in named arguments + foreach ($originalNamedArgs as $key => $value) { + if (is_string($key) && $value === null) { + // Convert camelCase to snake_case to check in config + $snakeKey = $this->convertCamelToSnake($key); + if (isset($parameterConfig[$snakeKey]) && ($parameterConfig[$snakeKey]['required'] ?? false)) { + throw new \InvalidArgumentException("Parameter $key is required but null was provided"); + } + } + } + } + + /** + * @param array $params + * @return array + * @throws RuntimeException If API operation fails or returns invalid data + * @throws InvalidArgumentException If method parameters are invalid + * @throws GuzzleException If HTTP request fails + */ + private function callOperation(string $method, array $params): array + { + $operationName = $this->convertMethodToOperation($method); + + if (!isset($this->config['operations'][$operationName])) { + throw new RuntimeException("Unknown operation: $operationName"); + } + + $operation = $this->config['operations'][$operationName]; + + $httpMethod = $operation['httpMethod'] ?? 'GET'; + $originalUri = $operation['uri'] ?? ''; + $uri = $this->buildUri($originalUri, $params); + + // For GET/DELETE requests, separate URI params from query params + $queryParams = $params; + if (in_array($httpMethod, ['GET', 'DELETE'])) { + // Input validation to prevent ReDoS attacks + if (strlen($originalUri) > self::MAX_URI_LENGTH) { + throw new InvalidArgumentException('URI too long'); + } + + if (substr_count($originalUri, '{') > self::MAX_PLACEHOLDERS) { + throw new InvalidArgumentException('Too many placeholders in URI'); + } + + // Find all {param} placeholders in the URI template using safe regex + // Allow 0 matches for URIs without placeholders + $matchCount = preg_match_all(self::PLACEHOLDER_PATTERN, $originalUri, $matches); + $uriParams = $matchCount > 0 ? $matches[1] : []; + + // Only remove URI parameters from a query if they were actually used in URI + // Keep parameter as query param if: + // 1. It's not a URI parameter, OR + // 2. It's a URI parameter but wasn't replaced (still contains {}) + // Also filter out null values + $queryParams = array_filter($params, function ($value, $key) use ($uriParams, $uri) { + $isNotUriParam = !in_array($key, $uriParams) || str_contains($uri, '{' . $key . '}'); + return $isNotUriParam && $value !== null; + }, ARRAY_FILTER_USE_BOTH); + } + + // Convert query parameters to strings for Guzzle compatibility (optimized) + $convertedQueryParams = $this->convertArrayParamsToString($queryParams); + + if ($httpMethod === 'POST') { + // Convert POST parameters too (optimized) + $convertedParams = $this->convertArrayParamsToString($params); + $response = $this->client->post($uri, ['json' => $convertedParams]); + } elseif ($httpMethod === 'PUT') { + // Convert PUT parameters too (optimized) + $convertedParams = $this->convertArrayParamsToString($params); + $response = $this->client->put($uri, ['json' => $convertedParams]); + } elseif ($httpMethod === 'DELETE') { + $response = $this->client->delete($uri, ['query' => $convertedQueryParams]); + } else { + $response = $this->client->get($uri, ['query' => $convertedQueryParams]); + } + + $body = $response->getBody(); + $body->rewind(); // Ensure we're at the beginning of the stream + $content = $body->getContents(); + + if (empty($content)) { + throw new RuntimeException('Empty response body received'); + } + + $data = json_decode($content, true); + + if (json_last_error() !== JSON_ERROR_NONE) { + throw new RuntimeException( + 'Invalid JSON response: ' . json_last_error_msg() . ' (Content: ' . substr($content, 0, 100) . ')' + ); + } + + if (!is_array($data)) { + throw new RuntimeException('Expected array response from API'); + } + + if (isset($data['error'])) { + throw new RuntimeException($data['message'] ?? 'API Error', $data['error']); + } + + return $data; + } + + /** + * Build URI with path parameters + * Optimized for performance - early exits and minimal string operations + * + * @param array $params + * @throws InvalidArgumentException If parameter names contain invalid characters + */ + private function buildUri(string $uri, array $params): string + { + // Fast path if no placeholders to replace + if (empty($params) || !str_contains($uri, '{')) { + return $uri; + } + + foreach ($params as $key => $value) { + $placeholder = '{' . $key . '}'; + + // Skip if placeholder not in URI (performance optimization) + if (!str_contains($uri, $placeholder)) { + continue; + } + + // Validate parameter name to prevent injection + if (!preg_match(self::PARAM_NAME_PATTERN, $key)) { + throw new InvalidArgumentException('Invalid parameter name: ' . $key); + } + + // URL-encode parameter values to prevent injection + $stringValue = $this->convertParameterToString($value); + $uri = str_replace($placeholder, rawurlencode($stringValue), $uri); + } + + return $uri; + } + + /** + * Convert parameter value to string with proper type handling + * Optimized for common cases (strings/ints) first + * + * @throws InvalidArgumentException If value cannot be converted to string + */ + private function convertParameterToString(mixed $value): string + { + // Fast path for most common types + if (is_string($value)) { + return $value; + } + if (is_int($value)) { + return (string)$value; + } + + return match (true) { + is_null($value) => '', + is_bool($value) => $value ? '1' : '0', + is_float($value) => number_format($value, 2, '.', ''), + $value instanceof DateTimeInterface => $value->format(DateTimeInterface::ATOM), + is_object($value) && method_exists($value, '__toString') => (string)$value, + is_array($value) => $this->encodeArrayParameter($value), + is_object($value) => throw new InvalidArgumentException( + 'Object parameters must implement __toString() method or be DateTime instances' + ), + default => throw new InvalidArgumentException('Unsupported parameter type: ' . gettype($value)) + }; + } + + /** + * Encode array parameter to JSON string with proper error handling + * + * @param array $value + * @throws InvalidArgumentException If JSON encoding fails + */ + private function encodeArrayParameter(array $value): string + { + try { + return json_encode($value, JSON_THROW_ON_ERROR); + } catch (\JsonException $e) { + throw new InvalidArgumentException('Failed to encode array parameter as JSON: ' . $e->getMessage(), 0, $e); + } + } + + /** + * Convert array parameters to strings more efficiently than array_map + * + * @param array $params + * @return array + */ + private function convertArrayParamsToString(array $params): array + { + if (empty($params)) { + return []; + } + + $converted = []; + foreach ($params as $key => $value) { + $converted[$key] = $this->convertParameterToString($value); + } + + return $converted; + } +} diff --git a/src/DiscogsClientFactory.php b/src/DiscogsClientFactory.php new file mode 100644 index 0000000..48a2426 --- /dev/null +++ b/src/DiscogsClientFactory.php @@ -0,0 +1,160 @@ +|GuzzleClient $optionsOrClient + */ + public static function create(array|GuzzleClient $optionsOrClient = []): DiscogsClient + { + // If GuzzleClient is passed directly, return it as-is + if ($optionsOrClient instanceof GuzzleClient) { + return new DiscogsClient($optionsOrClient); + } + + $config = ConfigCache::get(); + + // Merge user options with base configuration + $clientOptions = array_merge($optionsOrClient, [ + 'base_uri' => $config['baseUrl'], + ]); + + return new DiscogsClient(new GuzzleClient($clientOptions)); + } + + /** + * Create a client authenticated with OAuth 1.0a tokens + * Uses standard OAuth 1.0a with PLAINTEXT signature method as per RFC 5849 + * + * @param string $consumerKey OAuth consumer key + * @param string $consumerSecret OAuth consumer secret + * @param string $accessToken OAuth access token + * @param string $accessTokenSecret OAuth access token secret + * @param array|GuzzleClient $optionsOrClient + * + * @throws Exception If secure random number generation fails (PHP 8.2+: \Random\RandomException) + */ + public static function createWithOAuth( + string $consumerKey, + string $consumerSecret, + string $accessToken, + string $accessTokenSecret, + array|GuzzleClient $optionsOrClient = [] + ): DiscogsClient { + // If GuzzleClient is passed directly, return it as-is + // This allows full control over authentication for advanced users + if ($optionsOrClient instanceof GuzzleClient) { + return new DiscogsClient($optionsOrClient); + } + + // Generate OAuth 1.0a parameters as per RFC 5849 + $oauthParams = [ + 'oauth_consumer_key' => $consumerKey, + 'oauth_token' => $accessToken, + 'oauth_nonce' => bin2hex(random_bytes(16)), + 'oauth_signature_method' => 'PLAINTEXT', + 'oauth_timestamp' => (string)time(), + 'oauth_version' => '1.0', + ]; + + // Create signature as per RFC 5849 Section 3.4.4 (PLAINTEXT) + $oauthParams['oauth_signature'] = rawurlencode($consumerSecret) . '&' . rawurlencode($accessTokenSecret); + + // Build Authorization header as per RFC 5849 Section 3.5.1 (optimized) + $authParts = []; + foreach ($oauthParams as $key => $value) { + // oauth_signature is already properly encoded, don't double-encode it + $authParts[] = $key === 'oauth_signature' + ? $key . '="' . $value . '"' + : $key . '="' . rawurlencode($value) . '"'; + } + $authHeader = 'OAuth ' . implode(', ', $authParts); + + return self::createClientWithAuth($authHeader, $optionsOrClient); + } + + /** + * Internal helper to create authenticated clients with secure header handling + * + * @param string $authHeader Authorization header value + * @param array $optionsOrClient User options + */ + private static function createClientWithAuth(string $authHeader, array $optionsOrClient): DiscogsClient + { + $config = ConfigCache::get(); + + // Merge user options but ALWAYS override the Authorization header for security + $clientOptions = array_merge($optionsOrClient, [ + 'base_uri' => $config['baseUrl'], + ]); + + // Ensure our authentication headers take priority over user-provided ones + $clientOptions['headers'] = array_merge( + $optionsOrClient['headers'] ?? [], + ['Authorization' => $authHeader] + ); + + return new DiscogsClient(new GuzzleClient($clientOptions)); + } + + /** + * Create a client authenticated with only Consumer Key & Secret + * Sufficient for public endpoints like search, database lookups + * + * @param string $consumerKey OAuth consumer key + * @param string $consumerSecret OAuth consumer secret + * @param array|GuzzleClient $optionsOrClient + */ + public static function createWithConsumerCredentials( + string $consumerKey, + string $consumerSecret, + array|GuzzleClient $optionsOrClient = [] + ): DiscogsClient { + // If GuzzleClient is passed directly, return it as-is + // This allows full control over authentication for advanced users + if ($optionsOrClient instanceof GuzzleClient) { + return new DiscogsClient($optionsOrClient); + } + + // Discogs format for consumer credentials only + $authHeader = 'Discogs key=' . $consumerKey . ', secret=' . $consumerSecret; + + return self::createClientWithAuth($authHeader, $optionsOrClient); + } + + /** + * Create a client authenticated with Personal Access Token + * Uses Discogs-specific authentication format + * + * @param string $personalAccessToken Personal Access Token from Discogs + * @param array|GuzzleClient $optionsOrClient + */ + public static function createWithPersonalAccessToken( + string $personalAccessToken, + array|GuzzleClient $optionsOrClient = [] + ): DiscogsClient { + // If GuzzleClient is passed directly, return it as-is + // This allows full control over authentication for advanced users + if ($optionsOrClient instanceof GuzzleClient) { + return new DiscogsClient($optionsOrClient); + } + + // Discogs-specific authentication format for Personal Access Tokens + // Personal Access Token should work standalone without consumer credentials + $authHeader = 'Discogs token=' . $personalAccessToken; + + return self::createClientWithAuth($authHeader, $optionsOrClient); + } +} diff --git a/src/OAuthHelper.php b/src/OAuthHelper.php new file mode 100644 index 0000000..e5f59ec --- /dev/null +++ b/src/OAuthHelper.php @@ -0,0 +1,170 @@ +client = new GuzzleClient([ + 'base_uri' => $config['baseUrl'], + 'headers' => $config['client']['options']['headers'] + ]); + } else { + $this->client = $client; + } + } + + /** + * Get OAuth request token + * + * @param string $consumerKey Your application's consumer key + * @param string $consumerSecret Your application's consumer secret + * @param string $callbackUrl Your application's callback URL + * @return array{oauth_token: string, oauth_token_secret: string, oauth_callback_confirmed: string} + * @throws RuntimeException If OAuth request token cannot be obtained + * @throws GuzzleException If HTTP request fails + * @throws Exception If secure random number generation fails (PHP 8.2+: \Random\RandomException) + */ + public function getRequestToken(string $consumerKey, string $consumerSecret, string $callbackUrl): array + { + $params = [ + 'oauth_consumer_key' => $consumerKey, + 'oauth_nonce' => $this->generateNonce(), + 'oauth_signature_method' => 'PLAINTEXT', + 'oauth_timestamp' => (string)time(), + 'oauth_callback' => $callbackUrl, + 'oauth_version' => '1.0', + ]; + + $params['oauth_signature'] = $consumerSecret . '&'; + + $authHeader = $this->buildAuthorizationHeader($params); + + $response = $this->client->get('oauth/request_token', [ + 'headers' => ['Authorization' => $authHeader] + ]); + + $body = $response->getBody()->getContents(); + parse_str($body, $result); + + if (!isset($result['oauth_token'], $result['oauth_token_secret']) || + !is_string($result['oauth_token']) || !is_string($result['oauth_token_secret'])) { + throw new RuntimeException('Invalid OAuth request token response: ' . $body); + } + + $callbackConfirmed = $result['oauth_callback_confirmed'] ?? 'false'; + if (!is_string($callbackConfirmed)) { + $callbackConfirmed = 'false'; + } + + return [ + 'oauth_token' => $result['oauth_token'], + 'oauth_token_secret' => $result['oauth_token_secret'], + 'oauth_callback_confirmed' => $callbackConfirmed + ]; + } + + /** + * Generate cryptographically secure OAuth nonce + * + * @throws Exception If secure random number generation fails (PHP 8.2+: \Random\RandomException) + */ + private function generateNonce(): string + { + return bin2hex(random_bytes(self::NONCE_BYTES)); // Cryptographically secure nonce + } + + /** + * @param array $params + */ + private function buildAuthorizationHeader(array $params): string + { + $parts = []; + foreach ($params as $key => $value) { + $parts[] = $key . '="' . rawurlencode($value) . '"'; + } + + return 'OAuth ' . implode(', ', $parts); + } + + /** + * Generate authorization URL for user consent + * + * @param string $requestToken The request token obtained from getRequestToken() + * @return string Authorization URL + */ + public function getAuthorizationUrl(string $requestToken): string + { + return "https://discogs.com/oauth/authorize?oauth_token={$requestToken}"; + } + + /** + * Exchange request token for access token + * + * @param string $consumerKey Your application's consumer key + * @param string $consumerSecret Your application's consumer secret + * @param string $requestToken The request token from step 1 + * @param string $requestTokenSecret The request token secret from step 1 + * @param string $verifier The verification code from the callback + * @return array{oauth_token: string, oauth_token_secret: string} + * @throws RuntimeException If OAuth access token cannot be obtained + * @throws GuzzleException If HTTP request fails + * @throws Exception If secure random number generation fails (PHP 8.2+: \Random\RandomException) + */ + public function getAccessToken( + string $consumerKey, + string $consumerSecret, + string $requestToken, + string $requestTokenSecret, + string $verifier + ): array { + $params = [ + 'oauth_consumer_key' => $consumerKey, + 'oauth_token' => $requestToken, + 'oauth_verifier' => $verifier, + 'oauth_nonce' => $this->generateNonce(), + 'oauth_signature_method' => 'PLAINTEXT', + 'oauth_timestamp' => (string)time(), + 'oauth_version' => '1.0', + ]; + + $params['oauth_signature'] = $consumerSecret . '&' . $requestTokenSecret; + + $authHeader = $this->buildAuthorizationHeader($params); + + $response = $this->client->get('oauth/access_token', [ + 'headers' => ['Authorization' => $authHeader] + ]); + + $body = $response->getBody()->getContents(); + parse_str($body, $result); + + if (!isset($result['oauth_token'], $result['oauth_token_secret']) || + !is_string($result['oauth_token']) || !is_string($result['oauth_token_secret'])) { + throw new RuntimeException('Invalid OAuth access token response: ' . $body); + } + + return [ + 'oauth_token' => $result['oauth_token'], + 'oauth_token_secret' => $result['oauth_token_secret'] + ]; + } +} diff --git a/tests/Integration/AuthenticatedIntegrationTest.php b/tests/Integration/AuthenticatedIntegrationTest.php new file mode 100644 index 0000000..9bfa95a --- /dev/null +++ b/tests/Integration/AuthenticatedIntegrationTest.php @@ -0,0 +1,177 @@ +markTestSkipped('Consumer credentials not available'); + } + + $client = DiscogsClientFactory::createWithConsumerCredentials( + $consumerKey, + $consumerSecret + ); + + $results = $client->search(q: 'Daft Punk', type: 'artist', perPage: 1); + $this->assertValidSearchResponse($results); + $this->assertValidPaginationResponse($results); + $this->assertGreaterThan(0, $results['pagination']['items']); + + $artist = $client->getArtist(self::TEST_ARTIST_ID); + $this->assertValidArtistResponse($artist); + } + + public function testPersonalAccessTokenAuthentication(): void + { + if (!$this->hasPersonalToken()) { + $this->markTestSkipped('Personal Access Token not available'); + } + + $personalToken = getenv('DISCOGS_PERSONAL_ACCESS_TOKEN'); + if (!is_string($personalToken)) { + $this->markTestSkipped('Personal Access Token not available'); + } + + $client = DiscogsClientFactory::createWithPersonalAccessToken($personalToken); + + $results = $client->search(q: 'Daft Punk', type: 'artist', perPage: 1); + $this->assertValidSearchResponse($results); + $this->assertValidPaginationResponse($results); + $this->assertGreaterThan(0, $results['pagination']['items']); + + $artist = $client->getArtist(self::TEST_ARTIST_ID); + $this->assertValidArtistResponse($artist); + } + + private function hasPersonalToken(): bool + { + $personalToken = getenv('DISCOGS_PERSONAL_ACCESS_TOKEN'); + return $this->hasCredentials() + && is_string($personalToken) && $personalToken !== ''; + } + + private function hasCredentials(): bool + { + $consumerKey = getenv('DISCOGS_CONSUMER_KEY'); + $consumerSecret = getenv('DISCOGS_CONSUMER_SECRET'); + return is_string($consumerKey) && $consumerKey !== '' + && is_string($consumerSecret) && $consumerSecret !== ''; + } + + public function testOAuthAuthentication(): void + { + if (!$this->hasOAuthTokens()) { + $this->markTestSkipped('OAuth tokens not available'); + } + + $consumerKey = getenv('DISCOGS_CONSUMER_KEY'); + $consumerSecret = getenv('DISCOGS_CONSUMER_SECRET'); + $oauthToken = getenv('DISCOGS_OAUTH_TOKEN'); + $oauthTokenSecret = getenv('DISCOGS_OAUTH_TOKEN_SECRET'); + + if (!is_string($consumerKey) || !is_string($consumerSecret) || + !is_string($oauthToken) || !is_string($oauthTokenSecret)) { + $this->markTestSkipped('Required OAuth credentials not available'); + } + + $client = DiscogsClientFactory::createWithOAuth( + $consumerKey, + $consumerSecret, + $oauthToken, + $oauthTokenSecret + ); + + $identity = $client->getIdentity(); + $this->assertIsArray($identity); + $this->assertArrayHasKey('username', $identity); + $this->assertIsString($identity['username']); + + $results = $client->search(q: 'Taylor Swift', type: 'artist', perPage: 1); + $this->assertValidSearchResponse($results); + $this->assertValidPaginationResponse($results); + } + + private function hasOAuthTokens(): bool + { + $oauthToken = getenv('DISCOGS_OAUTH_TOKEN'); + $oauthTokenSecret = getenv('DISCOGS_OAUTH_TOKEN_SECRET'); + return $this->hasCredentials() + && is_string($oauthToken) && $oauthToken !== '' + && is_string($oauthTokenSecret) && $oauthTokenSecret !== ''; + } + + public function testRateLimitingBehavior(): void + { + $consumerKey = getenv('DISCOGS_CONSUMER_KEY'); + $consumerSecret = getenv('DISCOGS_CONSUMER_SECRET'); + + if (!is_string($consumerKey) || !is_string($consumerSecret)) { + $this->markTestSkipped('Consumer credentials not available'); + } + + $client = DiscogsClientFactory::createWithConsumerCredentials($consumerKey, $consumerSecret); + + $requests = 0; + $maxRequests = 3; + $testArtistIds = ['1', '2', '3']; + + for ($i = 0; $i < $maxRequests; $i++) { + try { + $artist = $client->getArtist($testArtistIds[$i]); + $requests++; + $this->assertValidArtistResponse($artist); + usleep(100000); + } catch (ClientException $e) { + if (str_contains($e->getMessage(), '429')) { + $this->addToAssertionCount(1); + break; + } + throw $e; + } + } + + $this->assertGreaterThan(0, $requests, 'Should complete at least one request'); + } + + public function testErrorHandlingWithAuthentication(): void + { + $consumerKey = getenv('DISCOGS_CONSUMER_KEY'); + $consumerSecret = getenv('DISCOGS_CONSUMER_SECRET'); + + if (!is_string($consumerKey) || !is_string($consumerSecret)) { + $this->markTestSkipped('Consumer credentials not available'); + } + + $client = DiscogsClientFactory::createWithConsumerCredentials($consumerKey, $consumerSecret); + + $this->expectException(ClientException::class); + $this->expectExceptionMessage('404'); + + $client->getArtist('999999999'); + } + + protected function setUp(): void + { + parent::setUp(); // Includes rate-limiting delay + + if (!$this->hasCredentials()) { + $this->markTestSkipped('Authenticated integration tests require credentials (GitHub Secrets)'); + } + } +} diff --git a/tests/Integration/AuthenticationLevelsTest.php b/tests/Integration/AuthenticationLevelsTest.php new file mode 100644 index 0000000..f192edb --- /dev/null +++ b/tests/Integration/AuthenticationLevelsTest.php @@ -0,0 +1,123 @@ +getArtist('5590213'); + $this->assertValidArtistResponse($artist); + $this->assertEquals('Billie Eilish', $artist['name']); + } + + public function testLevel2ConsumerCredentials(): void + { + $discogs = DiscogsClientFactory::createWithConsumerCredentials($this->consumerKey, $this->consumerSecret); + + $artist = $discogs->getArtist('1'); + $this->assertValidArtistResponse($artist); + + $searchResults = $discogs->search('Billie Eilish', 'artist'); + $this->assertValidSearchResponse($searchResults); + $this->assertGreaterThan(0, count($searchResults['results'])); + + $searchWithPagination = $discogs->search(q: 'Taylor Swift', perPage: 5); + $this->assertValidSearchResponse($searchWithPagination); + $this->assertValidPaginationResponse($searchWithPagination); + $this->assertEquals(5, $searchWithPagination['pagination']['per_page']); + } + + public function testLevel3PersonalAccessToken(): void + { + $discogs = DiscogsClientFactory::createWithPersonalAccessToken($this->personalToken); + + $artist = $discogs->getArtist('1'); + $this->assertValidArtistResponse($artist); + + $searchResults = $discogs->search('Jazz', 'release'); + $this->assertValidSearchResponse($searchResults); + $this->assertNotEmpty($searchResults['results']); + } + + public function testRateLimitingWithAuthentication(): void + { + $discogs = DiscogsClientFactory::createWithPersonalAccessToken($this->personalToken); + + for ($i = 0; $i < 3; $i++) { + $artist = $discogs->getArtist((string)(1 + $i)); + $this->assertValidArtistResponse($artist); + } + + $this->assertTrue(true); + } + + + + /** + * Test that user endpoints fail without a personal token + */ + public function testUserEndpointsFailWithoutPersonalToken(): void + { + $discogs = DiscogsClientFactory::createWithConsumerCredentials($this->consumerKey, $this->consumerSecret); + + $this->expectException(Exception::class); + $this->expectExceptionMessageMatches('/unauthorized|authentication|401|403/i'); + + // This should fail - consumer credentials aren't enough for user data + $discogs->getIdentity(); + } + + /** + * Test error handling with different authentication levels + */ + public function testErrorHandlingAcrossAuthLevels(): void + { + // Test with consumer credentials + $discogs = DiscogsClientFactory::createWithConsumerCredentials($this->consumerKey, $this->consumerSecret); + + try { + $discogs->getArtist('999999999'); // Non-existent artist + $this->fail('Should have thrown exception for non-existent artist'); + } catch (Exception $e) { + $this->assertStringContainsStringIgnoringCase('not found', $e->getMessage()); + } + + // Test with personal token + $discogsPersonal = DiscogsClientFactory::createWithPersonalAccessToken( + $this->personalToken + ); + + try { + $discogsPersonal->getUser('nonexistentusernamethatshouldnotexist123'); + $this->fail('Should have thrown exception for non-existent user'); + } 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') ?: ''; + + if (empty($this->consumerKey) || empty($this->consumerSecret) || empty($this->personalToken)) { + $this->markTestSkipped('Authentication credentials not available'); + } + } +} diff --git a/tests/Integration/AuthenticationTest.php b/tests/Integration/AuthenticationTest.php new file mode 100644 index 0000000..a890e6c --- /dev/null +++ b/tests/Integration/AuthenticationTest.php @@ -0,0 +1,218 @@ +jsonEncode([ + 'results' => [ + ['id' => 1, 'title' => 'Taylor Swift', 'type' => 'artist'] + ] + ])) + ]); + + $handlerStack = HandlerStack::create($mockHandler); + + // Track requests to verify auth header + $container = []; + + // Pass handler in options, not as GuzzleClient + $client = DiscogsClientFactory::createWithPersonalAccessToken( + 'test-personal-token', + ['handler' => $handlerStack] + ); + + // Add history tracking AFTER auth middleware was added by ClientFactory + $handlerStack->push(Middleware::history($container)); + + // Make a request that requires authentication + $result = $client->search('Taylor Swift', 'artist'); + + $this->assertCount(1, $container); + $request = $container[0]['request']; + $this->assertTrue($request->hasHeader('Authorization')); + + $authHeader = $request->getHeaderLine('Authorization'); + $this->assertValidPersonalTokenHeader($authHeader); + $this->assertStringContainsString('test-personal-token', $authHeader); + + // Verify the response was properly decoded + $this->assertIsArray($result); + $this->assertArrayHasKey('results', $result); + } + + /** + * @param array $data + * @throws Exception If test setup or execution fails + */ + private function jsonEncode(array $data): string + { + return json_encode($data) ?: '{}'; + } + + public function testOAuthSendsCorrectHeaders(): void + { + // Mock response from Discogs API + $mockHandler = new MockHandler([ + new Response(200, [], $this->jsonEncode([ + 'id' => 123, + 'username' => 'testuser', + 'resource_url' => 'https://api.discogs.com/users/testuser' + ])) + ]); + + $handlerStack = HandlerStack::create($mockHandler); + + // Track requests to verify auth header + $container = []; + + // Pass handler in options, not as GuzzleClient + $client = DiscogsClientFactory::createWithOAuth( + 'test-consumer-key', + 'test-consumer-secret', + 'test-access-token', + 'test-token-secret', + ['handler' => $handlerStack] + ); + + // Add history tracking AFTER auth middleware was added by ClientFactory + $handlerStack->push(Middleware::history($container)); + + // Make a request that requires OAuth + $result = $client->getIdentity(); + + $this->assertCount(1, $container); + $request = $container[0]['request']; + $this->assertTrue($request->hasHeader('Authorization')); + + $authHeader = $request->getHeaderLine('Authorization'); + $this->assertValidOAuthHeader($authHeader); + $this->assertStringContainsString('oauth_consumer_key="test-consumer-key"', $authHeader); + $this->assertStringContainsString('oauth_token="test-access-token"', $authHeader); + $this->assertStringContainsString('oauth_signature_method="PLAINTEXT"', $authHeader); + $this->assertStringContainsString('oauth_signature="test-consumer-secret&test-token-secret"', $authHeader); + + // Verify the response was properly decoded + $this->assertIsArray($result); + $this->assertArrayHasKey('username', $result); + $this->assertEquals('testuser', $result['username']); + } + + public function testPersonalAccessTokenWorksWithCollectionEndpoints(): void + { + // Mock collection response + $mockHandler = new MockHandler([ + new Response(200, [], $this->jsonEncode([ + 'folders' => [ + ['id' => 0, 'name' => 'All', 'count' => 5], + ['id' => 1, 'name' => 'Uncategorized', 'count' => 3] + ] + ])) + ]); + + $handlerStack = HandlerStack::create($mockHandler); + $container = []; + + // Pass handler in options + $client = DiscogsClientFactory::createWithPersonalAccessToken( + 'personal-token', + ['handler' => $handlerStack] + ); + + // Add history tracking AFTER auth middleware was added + $handlerStack->push(Middleware::history($container)); + + $result = $client->listCollectionFolders('testuser'); + + $this->assertCount(1, $container); + $request = $container[0]['request']; + $authHeader = $request->getHeaderLine('Authorization'); + $this->assertValidPersonalTokenHeader($authHeader); + $this->assertStringContainsString('personal-token', $authHeader); + + // Verify response + $this->assertArrayHasKey('folders', $result); + $this->assertCount(2, $result['folders']); + } + + public function testOAuthWorksWithMarketplaceEndpoints(): void + { + // Mock marketplace response + $mockHandler = new MockHandler([ + new Response(200, [], $this->jsonEncode([ + 'pagination' => ['items' => 0, 'page' => 1, 'pages' => 1], + 'orders' => [] + ])) + ]); + + $handlerStack = HandlerStack::create($mockHandler); + $container = []; + + // Pass handler in options + $client = DiscogsClientFactory::createWithOAuth( + 'consumer-key', + 'consumer-secret', + 'access-token', + 'token-secret', + ['handler' => $handlerStack] + ); + + // Add history tracking AFTER auth middleware was added + $handlerStack->push(Middleware::history($container)); + + $result = $client->getMarketplaceOrders('All'); + + $this->assertCount(1, $container); + $request = $container[0]['request']; + $authHeader = $request->getHeaderLine('Authorization'); + $this->assertValidOAuthHeader($authHeader); + $this->assertStringContainsString('oauth_token="access-token"', $authHeader); + + // Verify response + $this->assertArrayHasKey('orders', $result); + $this->assertArrayHasKey('pagination', $result); + } + + public function testUnauthenticatedClientDoesNotSendAuthHeaders(): void + { + // Mock public API response + $mockHandler = new MockHandler([ + new Response(200, [], $this->jsonEncode([ + 'id' => 139250, + 'name' => 'The Weeknd', + 'uri' => 'https://www.discogs.com/artist/139250-The-Weeknd' + ])) + ]); + + $handlerStack = HandlerStack::create($mockHandler); + $container = []; + $handlerStack->push(Middleware::history($container)); + + $client = DiscogsClientFactory::create(['handler' => $handlerStack]); + + $result = $client->getArtist('139250'); + + $this->assertCount(1, $container); + $request = $container[0]['request']; + $this->assertFalse($request->hasHeader('Authorization')); + + $this->assertValidArtistResponse($result); + $this->assertEquals('The Weeknd', $result['name']); + } +} diff --git a/tests/Integration/ClientWorkflowTest.php b/tests/Integration/ClientWorkflowTest.php index 5f38f91..8277513 100644 --- a/tests/Integration/ClientWorkflowTest.php +++ b/tests/Integration/ClientWorkflowTest.php @@ -4,138 +4,178 @@ namespace Calliostro\Discogs\Tests\Integration; -use Calliostro\Discogs\ClientFactory; +use Calliostro\Discogs\DiscogsClient; +use Calliostro\Discogs\DiscogsClientFactory; +use Exception; use GuzzleHttp\Client; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; use GuzzleHttp\Psr7\Response; -use PHPUnit\Framework\TestCase; +use PHPUnit\Framework\Attributes\CoversClass; +use ReflectionClass; +use ReflectionException; +use RuntimeException; /** * Integration tests for the complete client workflow - * - * @covers \Calliostro\Discogs\ClientFactory - * @covers \Calliostro\Discogs\DiscogsApiClient */ -final class ClientWorkflowTest extends TestCase +#[CoversClass(DiscogsClient::class)] +final class ClientWorkflowTest extends IntegrationTestCase { /** - * Helper method to safely encode JSON for Response body - * - * @param array $data + * @throws Exception If test setup or execution fails */ - private function jsonEncode(array $data): string - { - return json_encode($data) ?: '{}'; - } - public function testCompleteWorkflowWithFactoryAndApiCalls(): void { - // Create a mock handler with multiple responses $mockHandler = new MockHandler([ - new Response(200, [], $this->jsonEncode(['id' => '108713', 'name' => 'Aphex Twin'])), - new Response(200, [], $this->jsonEncode(['results' => [['title' => 'Selected Ambient Works']]])), - new Response(200, [], $this->jsonEncode(['id' => '1', 'name' => 'Warp Records'])), + new Response(200, [], $this->jsonEncode(['id' => '4470662', 'name' => 'Billie Eilish'])), + new Response(200, [], $this->jsonEncode(['results' => [['title' => 'Happier Than Ever']]])), + new Response(200, [], $this->jsonEncode(['id' => '12677', 'name' => 'Interscope Records'])), ]); $handlerStack = HandlerStack::create($mockHandler); $guzzleClient = new Client(['handler' => $handlerStack]); - // Create a client using factory with a custom Guzzle client - $client = new \Calliostro\Discogs\DiscogsApiClient($guzzleClient); - // Test multiple API calls - $artist = $client->artistGet(['id' => '108713']); - $this->assertEquals('Aphex Twin', $artist['name']); + $client = new DiscogsClient($guzzleClient); + + $artist = $client->getArtist('4470662'); + $this->assertValidArtistResponse($artist); + $this->assertEquals('Billie Eilish', $artist['name']); - $search = $client->search(['q' => 'Aphex Twin', 'type' => 'artist']); - $this->assertArrayHasKey('results', $search); + $search = $client->search('Billie Eilish', 'artist'); + $this->assertValidSearchResponse($search); - $label = $client->labelGet(['id' => '1']); - $this->assertEquals('Warp Records', $label['name']); + $label = $client->getLabel('12677'); + $this->assertIsArray($label); + $this->assertArrayHasKey('name', $label); + $this->assertEquals('Interscope Records', $label['name']); } + /** + * Helper method to safely encode JSON for Response body + * + * @param array $data + * @throws Exception If test setup or execution fails + */ + private function jsonEncode(array $data): string + { + return json_encode($data) ?: '{}'; + } + + /** + * @throws Exception If test setup or execution fails + */ public function testFactoryCreatesWorkingClients(): void { - // Test regular factory method - $client1 = ClientFactory::create(); - $this->assertInstanceOf(\Calliostro\Discogs\DiscogsApiClient::class, $client1); + $client1 = DiscogsClientFactory::create(); + /** @noinspection PhpConditionAlreadyCheckedInspection */ + $this->assertInstanceOf(DiscogsClient::class, $client1); + + $reflection = new ReflectionClass($client1); + $configProperty = $reflection->getProperty('config'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $configProperty->setAccessible(true); + $config = $configProperty->getValue($client1); + $this->assertIsArray($config); + $this->assertArrayHasKey('operations', $config); - // Test OAuth factory method - $client2 = ClientFactory::createWithOAuth('token', 'secret'); - $this->assertInstanceOf(\Calliostro\Discogs\DiscogsApiClient::class, $client2); - // Test token factory method - $client3 = ClientFactory::createWithToken('personal_token'); - $this->assertInstanceOf(\Calliostro\Discogs\DiscogsApiClient::class, $client3); + $client2 = DiscogsClientFactory::createWithOAuth('consumer_key', 'consumer_secret', 'token', 'token_secret'); + /** @noinspection PhpConditionAlreadyCheckedInspection */ + $this->assertInstanceOf(DiscogsClient::class, $client2); + + $reflection2 = new ReflectionClass($client2); + $configProperty2 = $reflection2->getProperty('config'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $configProperty2->setAccessible(true); + $config2 = $configProperty2->getValue($client2); + $this->assertIsArray($config2); + $this->assertArrayHasKey('operations', $config2); + + + $this->assertNotSame($client1, $client2); } + /** + * @throws ReflectionException If reflection operations fail + */ public function testServiceConfigurationIsLoaded(): void { - $client = ClientFactory::create(); + $client = DiscogsClientFactory::create(); - // This will fail if service.php is not properly loaded. - // We use reflection to check the config was loaded - $reflection = new \ReflectionClass($client); + + $reflection = new ReflectionClass($client); $configProperty = $reflection->getProperty('config'); + /** @noinspection PhpExpressionResultUnusedInspection */ $configProperty->setAccessible(true); $config = $configProperty->getValue($client); $this->assertIsArray($config); $this->assertArrayHasKey('operations', $config); - $this->assertArrayHasKey('artist.get', $config['operations']); + $this->assertArrayHasKey('getArtist', $config['operations']); $this->assertArrayHasKey('search', $config['operations']); } + /** + * @throws ReflectionException If reflection operations fail + */ public function testMethodNameToOperationConversion(): void { - // Create a client with a mock that we'll never use - // We just want to test the method name conversion $mockHandler = new MockHandler(); $handlerStack = HandlerStack::create($mockHandler); $guzzleClient = new Client(['handler' => $handlerStack]); - $client = new \Calliostro\Discogs\DiscogsApiClient($guzzleClient); + $client = new DiscogsClient($guzzleClient); // Use reflection to test the private method - $reflection = new \ReflectionClass($client); + $reflection = new ReflectionClass($client); $method = $reflection->getMethod('convertMethodToOperation'); + /** @noinspection PhpExpressionResultUnusedInspection */ $method->setAccessible(true); - // Test various conversions - $this->assertEquals('artist.get', $method->invokeArgs($client, ['artistGet'])); - $this->assertEquals('artist.releases', $method->invokeArgs($client, ['artistReleases'])); - $this->assertEquals('collection.folders', $method->invokeArgs($client, ['collectionFolders'])); - $this->assertEquals('order.messages', $method->invokeArgs($client, ['orderMessages'])); - $this->assertEquals('order.message.add', $method->invokeArgs($client, ['orderMessageAdd'])); + + $this->assertEquals('artistGet', $method->invokeArgs($client, ['artistGet'])); + $this->assertEquals('artistReleases', $method->invokeArgs($client, ['artistReleases'])); + $this->assertEquals('collectionFolders', $method->invokeArgs($client, ['collectionFolders'])); + $this->assertEquals('orderMessages', $method->invokeArgs($client, ['orderMessages'])); + $this->assertEquals('orderMessageAdd', $method->invokeArgs($client, ['orderMessageAdd'])); } + /** + * @throws ReflectionException If reflection operations fail + */ public function testUriBuilding(): void { - // Create a client to test URI building $mockHandler = new MockHandler(); $handlerStack = HandlerStack::create($mockHandler); $guzzleClient = new Client(['handler' => $handlerStack]); - $client = new \Calliostro\Discogs\DiscogsApiClient($guzzleClient); + $client = new DiscogsClient($guzzleClient); // Use reflection to test the private method - $reflection = new \ReflectionClass($client); + $reflection = new ReflectionClass($client); $method = $reflection->getMethod('buildUri'); + /** @noinspection PhpExpressionResultUnusedInspection */ $method->setAccessible(true); - // Test URI building with parameters - $uri = $method->invokeArgs($client, ['artists/{id}', ['id' => '108713']]); - $this->assertEquals('artists/108713', $uri); - $uri = $method->invokeArgs($client, ['users/{username}/collection/folders/{folder_id}/releases', [ - 'username' => 'testuser', - 'folder_id' => '0', - ]]); + $uri = $method->invokeArgs($client, ['artists/{id}', ['id' => '4470662']]); + $this->assertEquals('artists/4470662', $uri); + + $uri = $method->invokeArgs($client, [ + 'users/{username}/collection/folders/{folder_id}/releases', + [ + 'username' => 'testuser', + 'folder_id' => '0', + ] + ]); $this->assertEquals('users/testuser/collection/folders/0/releases', $uri); } + /** + * @throws Exception If test setup or execution fails + */ public function testErrorHandlingInCompleteWorkflow(): void { - // Create mock handler with error response $mockHandler = new MockHandler([ new Response(404, [], $this->jsonEncode([ 'error' => 404, @@ -145,11 +185,11 @@ public function testErrorHandlingInCompleteWorkflow(): void $handlerStack = HandlerStack::create($mockHandler); $guzzleClient = new Client(['handler' => $handlerStack]); - $client = new \Calliostro\Discogs\DiscogsApiClient($guzzleClient); + $client = new DiscogsClient($guzzleClient); - $this->expectException(\RuntimeException::class); + $this->expectException(RuntimeException::class); $this->expectExceptionMessage('Artist not found'); - $client->artistGet(['id' => '999999']); + $client->getArtist('999999'); } } diff --git a/tests/Integration/IntegrationTestCase.php b/tests/Integration/IntegrationTestCase.php new file mode 100644 index 0000000..47c44ae --- /dev/null +++ b/tests/Integration/IntegrationTestCase.php @@ -0,0 +1,149 @@ + $artist + */ + protected function assertValidArtistResponse(array $artist): void + { + $this->assertIsArray($artist); + $this->assertArrayHasKey('name', $artist); + $this->assertIsString($artist['name']); + $this->assertNotEmpty($artist['name']); + } + + /** + * Assert that response contains required release fields + * + * @param array $release + */ + protected function assertValidReleaseResponse(array $release): void + { + $this->assertIsArray($release); + $this->assertArrayHasKey('title', $release); + $this->assertIsString($release['title']); + $this->assertNotEmpty($release['title']); + } + + /** + * Assert that response contains required search result structure + * + * @param array $searchResults + */ + protected function assertValidSearchResponse(array $searchResults): void + { + $this->assertIsArray($searchResults); + $this->assertArrayHasKey('results', $searchResults); + $this->assertIsArray($searchResults['results']); + } + + /** + * Assert that response contains valid pagination structure + * + * @param array $response + */ + protected function assertValidPaginationResponse(array $response): void + { + $this->assertArrayHasKey('pagination', $response); + $this->assertIsArray($response['pagination']); + $this->assertArrayHasKey('page', $response['pagination']); + $this->assertArrayHasKey('per_page', $response['pagination']); + $this->assertArrayHasKey('items', $response['pagination']); + } + + /** + * Assert that the authentication header contains an expected OAuth format + */ + protected function assertValidOAuthHeader(string $authHeader): void + { + $this->assertStringContainsString('OAuth', $authHeader); + $this->assertStringContainsString('oauth_consumer_key=', $authHeader); + $this->assertStringContainsString('oauth_token=', $authHeader); + $this->assertStringContainsString('oauth_signature_method=', $authHeader); + $this->assertStringContainsString('oauth_signature=', $authHeader); + } + + /** + * Assert that the authentication header contains an expected Personal Access Token format + */ + protected function assertValidPersonalTokenHeader(string $authHeader): void + { + $this->assertStringContainsString('Discogs', $authHeader); + $this->assertStringContainsString('token=', $authHeader); + $this->assertStringNotContainsString('key=', $authHeader); + $this->assertStringNotContainsString('secret=', $authHeader); + } + + /** + * 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() && $e->getResponse()->getStatusCode() === 429) { + $attempt++; + + if ($attempt > $maxRetries) { + // Skip test instead of failing CI + $this->markTestSkipped( + 'API rate limit exceeded. Skipping test to prevent CI failure. ' . + 'This is expected behavior when multiple tests run quickly.' + ); + } + + // Exponential backoff: 5s, 10s (more aggressive) + $delay = 5 * $attempt; + sleep($delay); + continue; + } + + // Re-throw non-rate-limit exceptions + throw $e; + } + } + + return null; // This should never be reached, but satisfies PHPStan + } +} diff --git a/tests/Integration/PublicApiIntegrationTest.php b/tests/Integration/PublicApiIntegrationTest.php new file mode 100644 index 0000000..6fe8841 --- /dev/null +++ b/tests/Integration/PublicApiIntegrationTest.php @@ -0,0 +1,141 @@ +client->getReleaseStats('19929817'); + + $this->assertIsArray($stats); + + if (array_key_exists('is_offensive', $stats)) { + $this->assertIsBool($stats['is_offensive']); + if (count($stats) === 1) { + $this->assertArrayNotHasKey('num_have', $stats); + $this->assertArrayNotHasKey('num_want', $stats); + } + } + + if (array_key_exists('num_have', $stats) || array_key_exists('num_want', $stats)) { + if (isset($stats['num_have'])) { + $this->assertIsInt($stats['num_have']); + $this->assertGreaterThanOrEqual(0, $stats['num_have']); + } + if (isset($stats['num_want'])) { + $this->assertIsInt($stats['num_want']); + $this->assertGreaterThanOrEqual(0, $stats['num_want']); + } + } + + $this->assertNotEmpty($stats); + } + + + public function testCollectionStatsInReleaseEndpoint(): void + { + $release = $this->client->getRelease(19929817); + + $this->assertIsArray($release); + $this->assertArrayHasKey('community', $release); + $this->assertArrayHasKey('have', $release['community']); + $this->assertArrayHasKey('want', $release['community']); + + $this->assertIsInt($release['community']['have']); + $this->assertIsInt($release['community']['want']); + $this->assertGreaterThan(0, $release['community']['have']); + $this->assertGreaterThan(0, $release['community']['want']); + } + + public function testBasicDatabaseMethods(): void + { + $artist = $this->client->getArtist(5590213); + $this->assertValidArtistResponse($artist); + + $release = $this->client->getRelease(19929817); + $this->assertValidReleaseResponse($release); + + $master = $this->client->getMaster(1524311); + $this->assertIsArray($master); + $this->assertArrayHasKey('title', $master); + $this->assertIsString($master['title']); + + $label = $this->client->getLabel(2311); + $this->assertIsArray($label); + $this->assertArrayHasKey('name', $label); + $this->assertIsString($label['name']); + } + + /** + * Test Community Release Rating endpoint + */ + public function testCommunityReleaseRating(): void + { + $rating = $this->client->getCommunityReleaseRating('19929817'); + + $this->assertIsArray($rating); + $this->assertArrayHasKey('rating', $rating); + $this->assertArrayHasKey('release_id', $rating); + $this->assertEquals(19929817, $rating['release_id']); + + $this->assertIsArray($rating['rating']); + $this->assertArrayHasKey('average', $rating['rating']); + $this->assertArrayHasKey('count', $rating['rating']); + } + + public function testPaginationOnListEndpoints(): void + { + $releases = $this->client->listArtistReleases('5590213', null, null, 2, 1); + + $this->assertIsArray($releases); + $this->assertArrayHasKey('releases', $releases); + $this->assertValidPaginationResponse($releases); + + $this->assertCount(2, $releases['releases']); + $this->assertEquals(1, $releases['pagination']['page']); + $this->assertEquals(2, $releases['pagination']['per_page']); + } + + /** + * Test that known API changes are properly handled + */ + public function testApiChangesCompatibility(): void + { + // getReleaseStats changed format - verify our code handles it + $stats = $this->client->getReleaseStats('19929817'); + $this->assertEquals(['is_offensive' => false], $stats); + + $release = $this->client->getRelease(19929817); + $this->assertArrayHasKey('community', $release); + $this->assertIsInt($release['community']['have']); + $this->assertIsInt($release['community']['want']); + } + + /** + * Test error handling for non-existent resources + */ + public function testErrorHandling(): void + { + $this->expectException(Exception::class); + $this->expectExceptionMessageMatches('/not found|does not exist/i'); + + // This should throw an exception for non-existent artist + $this->client->getArtist(999999999); + } + + protected function setUp(): void + { + parent::setUp(); // Includes rate-limiting delay + $this->client = DiscogsClientFactory::create(); + } +} diff --git a/tests/Unit/ClientFactoryTest.php b/tests/Unit/ClientFactoryTest.php deleted file mode 100644 index f61f9c1..0000000 --- a/tests/Unit/ClientFactoryTest.php +++ /dev/null @@ -1,80 +0,0 @@ -assertInstanceOf(DiscogsApiClient::class, $client); - } - - public function testCreateWithOAuthReturnsDiscogsApiClient(): void - { - $client = ClientFactory::createWithOAuth('token', 'secret'); - - $this->assertInstanceOf(DiscogsApiClient::class, $client); - } - - public function testCreateWithTokenReturnsDiscogsApiClient(): void - { - $client = ClientFactory::createWithToken('personal_access_token'); - - $this->assertInstanceOf(DiscogsApiClient::class, $client); - } - - public function testCreateWithCustomUserAgentReturnsDiscogsApiClient(): void - { - $client = ClientFactory::create('CustomApp/1.0'); - - $this->assertInstanceOf(DiscogsApiClient::class, $client); - } - - public function testCreateWithAllParametersReturnsDiscogsApiClient(): void - { - $options = ['timeout' => 60]; - $client = ClientFactory::create('CustomApp/1.0', $options); - - $this->assertInstanceOf(DiscogsApiClient::class, $client); - } - - public function testCreateWithOAuthAndAllParameters(): void - { - $token = 'test_access_token'; - $tokenSecret = 'test_access_token_secret'; - $userAgent = 'CustomApp/1.0'; - $options = ['timeout' => 60]; - - $client = ClientFactory::createWithOAuth( - $token, - $tokenSecret, - $userAgent, - $options - ); - - $this->assertInstanceOf(DiscogsApiClient::class, $client); - } - - public function testCreateWithTokenAndAllParameters(): void - { - $token = 'test_personal_token'; - $userAgent = 'CustomApp/1.0'; - $options = ['timeout' => 60]; - - $client = ClientFactory::createWithToken($token, $userAgent, $options); - - $this->assertInstanceOf(DiscogsApiClient::class, $client); - } -} diff --git a/tests/Unit/ConfigCacheTest.php b/tests/Unit/ConfigCacheTest.php new file mode 100644 index 0000000..6d408d2 --- /dev/null +++ b/tests/Unit/ConfigCacheTest.php @@ -0,0 +1,138 @@ +assertIsArray($config); + $this->assertArrayHasKey('operations', $config); + $this->assertArrayHasKey('client', $config); + $this->assertArrayHasKey('baseUrl', $config); + } + + public function testGetCachesConfigurationAfterFirstCall(): void + { + // The first call loads from the file + $config1 = ConfigCache::get(); + + // The second call should return a cached version + $config2 = ConfigCache::get(); + + // Both should be identical + $this->assertSame($config1, $config2); + $this->assertIsArray($config1); + } + + public function testClearResetsCache(): void + { + // Load config first time + $config1 = ConfigCache::get(); + $this->assertIsArray($config1); + + // Clear cache + ConfigCache::clear(); + + // Load config again (should reload from the file) + $config2 = ConfigCache::get(); + + // Should be equal but different object reference + $this->assertEquals($config1, $config2); + $this->assertIsArray($config2); + } + + public function testConstructorIsPrivateToPreventInstantiation(): void + { + $reflection = new ReflectionClass(ConfigCache::class); + $constructor = $reflection->getConstructor(); + + $this->assertNotNull($constructor); + $this->assertTrue($constructor->isPrivate()); + + // Ensure the constructor method is defined (even if empty) + $this->assertTrue(method_exists(ConfigCache::class, '__construct')); + } + + public function testCannotInstantiateConfigCache(): void + { + $reflection = new ReflectionClass(ConfigCache::class); + $constructor = $reflection->getConstructor(); + + $this->assertNotNull($constructor); + $this->assertTrue($constructor->isPrivate()); + + // Test that calling the constructor via reflection throws an error + $this->expectException(\ReflectionException::class); + $constructor->invoke(null); + } + + public function testConfigContainsExpectedStructure(): void + { + $config = ConfigCache::get(); + + // Verify the basic structure expected by the application + $this->assertArrayHasKey('operations', $config); + $this->assertArrayHasKey('client', $config); + $this->assertArrayHasKey('baseUrl', $config); + + // Operations should be an array of operation definitions + $this->assertIsArray($config['operations']); + $this->assertNotEmpty($config['operations']); + + // Client should contain configuration + $this->assertIsArray($config['client']); + + // BaseUrl should be a string + $this->assertIsString($config['baseUrl']); + $this->assertStringStartsWith('https://', $config['baseUrl']); + } + + public function testMultipleGetCallsReturnSameInstance(): void + { + $config1 = ConfigCache::get(); + $config2 = ConfigCache::get(); + $config3 = ConfigCache::get(); + + // All should be exactly the same reference + $this->assertSame($config1, $config2); + $this->assertSame($config2, $config3); + $this->assertSame($config1, $config3); + } + + public function testClearAndGetCycleWorksCorrectly(): void + { + // Load initial config + $config1 = ConfigCache::get(); + $this->assertIsArray($config1); + + // Clear and reload multiple times + for ($i = 0; $i < 3; $i++) { + ConfigCache::clear(); + $config = ConfigCache::get(); + $this->assertIsArray($config); + $this->assertEquals($config1, $config); + } + } + + protected function setUp(): void + { + parent::setUp(); + // Clear the cache before each test + ConfigCache::clear(); + } + + protected function tearDown(): void + { + // Clear cache after each test to prevent test pollution + ConfigCache::clear(); + parent::tearDown(); + } +} diff --git a/tests/Unit/DiscogsApiClientTest.php b/tests/Unit/DiscogsApiClientTest.php deleted file mode 100644 index 7817e97..0000000 --- a/tests/Unit/DiscogsApiClientTest.php +++ /dev/null @@ -1,451 +0,0 @@ -mockHandler = new MockHandler(); - $handlerStack = HandlerStack::create($this->mockHandler); - $guzzleClient = new Client(['handler' => $handlerStack]); - - $this->client = new DiscogsApiClient($guzzleClient); - } - - /** - * Helper method to safely encode JSON for Response body - * - * @param array $data - */ - private function jsonEncode(array $data): string - { - return json_encode($data) ?: '{}'; - } - - public function testArtistGetMethodCallsCorrectEndpoint(): void - { - $this->mockHandler->append( - new Response(200, [], $this->jsonEncode(['id' => '108713', 'name' => 'Aphex Twin'])) - ); - - $result = $this->client->artistGet(['id' => '108713']); - - $this->assertEquals(['id' => '108713', 'name' => 'Aphex Twin'], $result); - } - - public function testSearchMethodCallsCorrectEndpoint(): void - { - $this->mockHandler->append( - new Response(200, [], $this->jsonEncode(['results' => [['title' => 'Nirvana - Nevermind']]])) - ); - - $result = $this->client->search(['q' => 'Nirvana', 'type' => 'release']); - - $this->assertEquals(['results' => [['title' => 'Nirvana - Nevermind']]], $result); - } - - public function testReleaseGetMethodCallsCorrectEndpoint(): void - { - $this->mockHandler->append( - new Response(200, [], $this->jsonEncode(['id' => 249504, 'title' => 'Nevermind'])) - ); - - $result = $this->client->releaseGet(['id' => '249504']); - - $this->assertEquals(['id' => 249504, 'title' => 'Nevermind'], $result); - } - - public function testMethodNameConversionWorks(): void - { - $this->mockHandler->append( - new Response(200, [], $this->jsonEncode(['id' => '1', 'name' => 'Warp Records'])) - ); - - $result = $this->client->labelGet(['id' => '1']); - - $this->assertEquals(['id' => '1', 'name' => 'Warp Records'], $result); - } - - public function testUnknownOperationThrowsException(): void - { - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Unknown operation: unknown.method'); - - // @phpstan-ignore-next-line - Testing invalid method call - $this->client->unknownMethod(); - } - - public function testInvalidJsonResponseThrowsException(): void - { - $this->mockHandler->append( - new Response(200, [], 'invalid json') - ); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Invalid JSON response:'); - - $this->client->artistGet(['id' => '108713']); - } - - public function testApiErrorResponseThrowsException(): void - { - $this->mockHandler->append( - new Response(400, [], $this->jsonEncode([ - 'error' => 400, - 'message' => 'Bad Request: Invalid ID', - ])) - ); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Bad Request: Invalid ID'); - - $this->client->artistGet(['id' => 'invalid']); - } - - public function testApiErrorResponseWithoutMessageThrowsException(): void - { - $this->mockHandler->append( - new Response(200, [], $this->jsonEncode([ - 'error' => 400, - // No 'message' field, should use default 'API Error' - ])) - ); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('API Error'); - - $this->client->artistGet(['id' => '123']); - } - - public function testComplexMethodNameConversion(): void - { - $this->mockHandler->append( - new Response(200, [], $this->jsonEncode(['messages' => []])) - ); - - $result = $this->client->orderMessages(['order_id' => '123']); - - $this->assertEquals(['messages' => []], $result); - } - - public function testCollectionItemsMethod(): void - { - $this->mockHandler->append( - new Response(200, [], $this->jsonEncode(['releases' => []])) - ); - - $result = $this->client->collectionItems(['username' => 'user', 'folder_id' => '0']); - - $this->assertEquals(['releases' => []], $result); - } - - public function testPostMethodWithJsonPayload(): void - { - $this->mockHandler->append( - new Response(201, [], $this->jsonEncode(['listing_id' => '12345'])) - ); - - $result = $this->client->listingCreate([ - 'release_id' => '249504', - 'condition' => 'Mint (M)', - 'price' => '25.00', - 'status' => 'For Sale', - ]); - - $this->assertEquals(['listing_id' => '12345'], $result); - } - - public function testReleaseRatingGetMethod(): void - { - $this->mockHandler->append( - new Response(200, [], $this->jsonEncode(['username' => 'testuser', 'release_id' => 249504, 'rating' => 5])) - ); - - $result = $this->client->releaseRatingGet(['release_id' => 249504, 'username' => 'testuser']); - - $this->assertEquals(['username' => 'testuser', 'release_id' => 249504, 'rating' => 5], $result); - } - - public function testCollectionFoldersGet(): void - { - $this->mockHandler->append( - new Response(200, [], $this->jsonEncode([ - 'folders' => [ - ['id' => 0, 'name' => 'All', 'count' => 23], - ['id' => 1, 'name' => 'Uncategorized', 'count' => 20], - ], - ])) - ); - - $result = $this->client->collectionFolders(['username' => 'testuser']); - - $this->assertArrayHasKey('folders', $result); - $this->assertCount(2, $result['folders']); - } - - public function testWantlistGet(): void - { - $this->mockHandler->append( - new Response(200, [], $this->jsonEncode([ - 'wants' => [ - ['id' => 1867708, 'rating' => 4, 'basic_information' => ['title' => 'Year Zero']], - ], - ])) - ); - - $result = $this->client->wantlistGet(['username' => 'testuser']); - - $this->assertArrayHasKey('wants', $result); - $this->assertCount(1, $result['wants']); - } - - public function testMarketplaceFeeCalculation(): void - { - $this->mockHandler->append( - new Response(200, [], $this->jsonEncode(['value' => 0.42, 'currency' => 'USD'])) - ); - - $result = $this->client->marketplaceFee(['price' => 10.00]); - - $this->assertEquals(['value' => 0.42, 'currency' => 'USD'], $result); - } - - public function testListingGetMethod(): void - { - $this->mockHandler->append( - new Response(200, [], $this->jsonEncode([ - 'id' => 172723812, - 'status' => 'For Sale', - 'price' => ['currency' => 'USD', 'value' => 120], - ])) - ); - - $result = $this->client->listingGet(['listing_id' => 172723812]); - - $this->assertEquals(172723812, $result['id']); - $this->assertEquals('For Sale', $result['status']); - } - - public function testUserEdit(): void - { - $this->mockHandler->append( - new Response(200, [], $this->jsonEncode(['success' => true, 'username' => 'testuser'])) - ); - - $result = $this->client->userEdit([ - 'username' => 'testuser', - 'name' => 'Test User', - 'location' => 'Test City', - ]); - - $this->assertEquals(['success' => true, 'username' => 'testuser'], $result); - } - - public function testPutMethodHandling(): void - { - $this->mockHandler->append( - new Response(200, [], $this->jsonEncode(['rating' => 5, 'release_id' => 249504])) - ); - - $result = $this->client->releaseRatingPut([ - 'release_id' => 249504, - 'username' => 'testuser', - 'rating' => 5, - ]); - - $this->assertEquals(['rating' => 5, 'release_id' => 249504], $result); - } - - public function testDeleteMethodHandling(): void - { - $this->mockHandler->append( - new Response(204, [], '{}') - ); - - $result = $this->client->releaseRatingDelete([ - 'release_id' => 249504, - 'username' => 'testuser', - ]); - - $this->assertEquals([], $result); - } - - public function testHttpExceptionHandling(): void - { - $this->mockHandler->append( - new \GuzzleHttp\Exception\RequestException( - 'Connection failed', - new \GuzzleHttp\Psr7\Request('GET', 'test') - ) - ); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('HTTP request failed: Connection failed'); - - $this->client->artistGet(['id' => '123']); - } - - public function testNonArrayResponseHandling(): void - { - $this->mockHandler->append( - new Response(200, [], '"not an array"') - ); - - $this->expectException(\RuntimeException::class); - $this->expectExceptionMessage('Expected array response from API'); - - $this->client->artistGet(['id' => '123']); - } - - public function testUriBuilding(): void - { - $this->mockHandler->append( - new Response(200, [], $this->jsonEncode(['id' => 123, 'name' => 'Test Artist'])) - ); - - $result = $this->client->artistGet(['id' => 123]); - - $this->assertEquals(['id' => 123, 'name' => 'Test Artist'], $result); - } - - public function testComplexMethodNameConversionWithMultipleParts(): void - { - $this->mockHandler->append( - new Response(200, [], $this->jsonEncode(['messages' => []])) - ); - - $result = $this->client->orderMessageAdd([ - 'order_id' => '123-456', - 'message' => 'Test message', - ]); - - $this->assertEquals(['messages' => []], $result); - } - - public function testEmptyParametersHandling(): void - { - $this->mockHandler->append( - new Response(200, [], $this->jsonEncode(['results' => []])) - ); - - // Test calling without parameters - $result = $this->client->search(); - - $this->assertEquals(['results' => []], $result); - } - - public function testConvertMethodToOperationWithEmptyString(): void - { - $this->mockHandler->append( - new Response(200, [], $this->jsonEncode(['success' => true])) - ); - - // This will call the protected convertMethodToOperation indirectly - // by testing edge cases in method name conversion - try { - // @phpstan-ignore-next-line - Testing invalid method call - $this->client->testMethodName(); - } catch (\RuntimeException $e) { - $this->assertStringContainsString('Unknown operation', $e->getMessage()); - } - } - - public function testBuildUriWithNoParameters(): void - { - $this->mockHandler->append( - new Response(200, [], $this->jsonEncode(['results' => []])) - ); - - // Test URI building with no parameters (should not replace anything) - $result = $this->client->search(['q' => 'test']); - - $this->assertEquals(['results' => []], $result); - } - - public function testMethodCallWithNullParameters(): void - { - $this->mockHandler->append( - new Response(200, [], $this->jsonEncode(['success' => true])) - ); - - // Test method call with null as parameters - should be converted to empty array - // @phpstan-ignore-next-line - Testing parameter validation - $result = $this->client->search(null); - - $this->assertEquals(['success' => true], $result); - } - - public function testConvertMethodToOperationWithEdgeCases(): void - { - // Test the convertMethodToOperation method with edge cases - $reflection = new \ReflectionClass($this->client); - $method = $reflection->getMethod('convertMethodToOperation'); - $method->setAccessible(true); - - // Test with empty string (should return empty string) - $result = $method->invokeArgs($this->client, ['']); - $this->assertEquals('', $result); - - // Test with a single lowercase word - $result = $method->invokeArgs($this->client, ['test']); - $this->assertEquals('test', $result); - - // Test with mixed case scenarios - $result = $method->invokeArgs($this->client, ['ArtistGetReleases']); - $this->assertEquals('artist.get.releases', $result); - } - - public function testBuildUriWithComplexParameters(): void - { - // Test the buildUri method directly with complex scenarios - $reflection = new \ReflectionClass($this->client); - $method = $reflection->getMethod('buildUri'); - $method->setAccessible(true); - - // Test with leading slash - $result = $method->invokeArgs($this->client, ['/artists/{id}/releases', ['id' => '123']]); - $this->assertEquals('artists/123/releases', $result); - - // Test with no parameters to replace - $result = $method->invokeArgs($this->client, ['artists', []]); - $this->assertEquals('artists', $result); - - // Test with multiple parameters - $result = $method->invokeArgs($this->client, ['/users/{username}/collection/folders/{folder_id}', [ - 'username' => 'testuser', - 'folder_id' => '1', - 'extra' => 'ignored', // Should be ignored - ]]); - $this->assertEquals('users/testuser/collection/folders/1', $result); - } - - public function testPregSplitEdgeCaseHandling(): void - { - // Test case where preg_split might return false - this might be the missing line - $reflection = new \ReflectionClass($this->client); - $method = $reflection->getMethod('convertMethodToOperation'); - $method->setAccessible(true); - - // Test with a long method name (100 characters) to potentially trigger edge cases - $longMethodName = str_repeat('A', 100) . 'Get'; - $result = $method->invokeArgs($this->client, [$longMethodName]); - // This should still work, converting the long name properly - $this->assertIsString($result); - } -} diff --git a/tests/Unit/DiscogsClientFactoryTest.php b/tests/Unit/DiscogsClientFactoryTest.php new file mode 100644 index 0000000..45ff7f3 --- /dev/null +++ b/tests/Unit/DiscogsClientFactoryTest.php @@ -0,0 +1,229 @@ +assertInstanceOf(DiscogsClient::class, $client1); + + $client2 = DiscogsClientFactory::create(['timeout' => 60]); + /** @noinspection PhpConditionAlreadyCheckedInspection */ + $this->assertInstanceOf(DiscogsClient::class, $client2); + + $client3 = DiscogsClientFactory::createWithConsumerCredentials('consumer_key', 'consumer_secret'); + /** @noinspection PhpConditionAlreadyCheckedInspection */ + $this->assertInstanceOf(DiscogsClient::class, $client3); + + $client4 = DiscogsClientFactory::createWithConsumerCredentials('key', 'secret', ['timeout' => 60]); + /** @noinspection PhpConditionAlreadyCheckedInspection */ + $this->assertInstanceOf(DiscogsClient::class, $client4); + + $client5 = DiscogsClientFactory::createWithPersonalAccessToken('personal_access_token'); + /** @noinspection PhpConditionAlreadyCheckedInspection */ + $this->assertInstanceOf(DiscogsClient::class, $client5); + + $client6 = DiscogsClientFactory::createWithPersonalAccessToken('token', ['timeout' => 60]); + /** @noinspection PhpConditionAlreadyCheckedInspection */ + $this->assertInstanceOf(DiscogsClient::class, $client6); + + $guzzleClient = new Client(); + $client7 = DiscogsClientFactory::create($guzzleClient); + /** @noinspection PhpConditionAlreadyCheckedInspection */ + $this->assertInstanceOf(DiscogsClient::class, $client7); + + $client8 = DiscogsClientFactory::createWithConsumerCredentials('key', 'secret', $guzzleClient); + /** @noinspection PhpConditionAlreadyCheckedInspection */ + $this->assertInstanceOf(DiscogsClient::class, $client8); + + $client9 = DiscogsClientFactory::createWithPersonalAccessToken('token', $guzzleClient); + /** @noinspection PhpConditionAlreadyCheckedInspection */ + $this->assertInstanceOf(DiscogsClient::class, $client9); + + + $clients = [$client1, $client2, $client3, $client4, $client5, $client6, $client7, $client8, $client9]; + for ($i = 0; $i < count($clients); $i++) { + for ($j = $i + 1; $j < count($clients); $j++) { + $this->assertNotSame($clients[$i], $clients[$j], "Client $i and $j should be different instances"); + } + } + } + + /** + * @throws Exception If test setup or execution fails + */ + public function testOAuthFactoryMethods(): void + { + $client1 = DiscogsClientFactory::createWithOAuth('key', 'secret', 'token', 'token_secret'); + /** @noinspection PhpConditionAlreadyCheckedInspection */ + $this->assertInstanceOf(DiscogsClient::class, $client1); + + $client2 = DiscogsClientFactory::createWithOAuth('key', 'secret', 'token', 'token_secret', ['timeout' => 60]); + /** @noinspection PhpConditionAlreadyCheckedInspection */ + $this->assertInstanceOf(DiscogsClient::class, $client2); + + $guzzleClient = new Client(); + $client3 = DiscogsClientFactory::createWithOAuth('key', 'secret', 'token', 'token_secret', $guzzleClient); + /** @noinspection PhpConditionAlreadyCheckedInspection */ + $this->assertInstanceOf(DiscogsClient::class, $client3); + + + $this->assertNotSame($client1, $client2); + $this->assertNotSame($client2, $client3); + $this->assertNotSame($client1, $client3); + } + + /** + * @throws Exception If test setup or execution fails + */ + public function testCreateWithOAuthAddsAuthorizationHeader(): void + { + // Mock handler to capture the request + $mockHandler = new MockHandler([ + new Response(200, [], '{"id": 1, "name": "Test Artist"}') + ]); + + + $handlerStack = HandlerStack::create($mockHandler); + + + $container = []; + + + $client = DiscogsClientFactory::createWithOAuth( + 'consumer_key', + 'consumer_secret', + 'token', + 'token_secret', + ['handler' => $handlerStack] + ); + + + $handlerStack->push(Middleware::history($container)); + + // Make a valid request to trigger the middleware + $client->getArtist(1); + + // Should have one request with an auth header + $this->assertCount(1, $container); + $this->assertTrue($container[0]['request']->hasHeader('Authorization')); + $authHeader = $container[0]['request']->getHeaderLine('Authorization'); + $this->assertValidOAuthHeader($authHeader); + $this->assertStringContainsString('oauth_consumer_key="consumer_key"', $authHeader); + $this->assertStringContainsString('oauth_token="token"', $authHeader); + } + + public function testCreateWithPersonalAccessTokenAddsAuthorizationHeader(): void + { + // Mock handler to capture the request + $mockHandler = new MockHandler([ + new Response(200, [], '{"id": 1, "name": "Test Artist"}') + ]); + + // Create handler stack + $handlerStack = HandlerStack::create($mockHandler); + + // Track requests to verify auth header + $container = []; + + // Test Personal Access Token authentication + $client = DiscogsClientFactory::createWithPersonalAccessToken('personal_token', ['handler' => $handlerStack]); + + // Add history tracking AFTER auth middleware was added + $handlerStack->push(Middleware::history($container)); + + // Make a valid request to trigger the middleware + $client->getArtist(1); + + // Should have one request with an auth header + $this->assertCount(1, $container); + $this->assertTrue($container[0]['request']->hasHeader('Authorization')); + $authHeader = $container[0]['request']->getHeaderLine('Authorization'); + $this->assertValidPersonalTokenHeader($authHeader); + $this->assertStringContainsString('token=personal_token', $authHeader); + } + + public function testCreateWithConsumerCredentialsAddsAuthorizationHeader(): void + { + // Mock handler to capture the request + $mockHandler = new MockHandler([ + new Response(200, [], '{"id": 1, "name": "Test Artist"}') + ]); + + // Create handler stack + $handlerStack = HandlerStack::create($mockHandler); + + // Track requests to verify auth header + $container = []; + + // Test Consumer Credentials authentication + $client = DiscogsClientFactory::createWithConsumerCredentials( + 'consumer_key', + 'consumer_secret', + ['handler' => $handlerStack] + ); + + // Add history tracking AFTER auth middleware was added + $handlerStack->push(Middleware::history($container)); + + // Make a valid request to trigger the middleware + $client->getArtist(1); + + // Should have one request with an auth header + $this->assertCount(1, $container); + $this->assertTrue($container[0]['request']->hasHeader('Authorization')); + $authHeader = $container[0]['request']->getHeaderLine('Authorization'); + $this->assertStringContainsString('Discogs', $authHeader); + $this->assertStringContainsString('key=consumer_key', $authHeader); + $this->assertStringContainsString('secret=consumer_secret', $authHeader); + $this->assertStringNotContainsString('token=', $authHeader); + } + + public function testConfigCaching(): void + { + // Test that config caching works across multiple factory calls + // This exercises both the initial loading and cached paths + DiscogsClientFactory::create(); + DiscogsClientFactory::createWithConsumerCredentials('key', 'secret'); + DiscogsClientFactory::createWithPersonalAccessToken('token'); + + $this->assertTrue(true); + } + + public function testConfigLoadingFromFresh(): void + { + // Clear the ConfigCache to test the initial loading path + ConfigCache::clear(); + + // This should trigger the config loading path + $client = DiscogsClientFactory::create(); + /** @noinspection PhpConditionAlreadyCheckedInspection */ + $this->assertInstanceOf(DiscogsClient::class, $client); + $config = ConfigCache::get(); + $this->assertIsArray($config); + $this->assertArrayHasKey('baseUrl', $config); + } +} diff --git a/tests/Unit/DiscogsClientTest.php b/tests/Unit/DiscogsClientTest.php new file mode 100644 index 0000000..2a6d846 --- /dev/null +++ b/tests/Unit/DiscogsClientTest.php @@ -0,0 +1,1968 @@ +mockHandler->append( + new Response(200, [], $this->jsonEncode(['id' => '4470662', 'name' => 'Billie Eilish'])) + ); + + $result = $this->client->getArtist(4470662); + + $this->assertValidArtistResponse($result); + $this->assertEquals('Billie Eilish', $result['name']); + } + + + public function testSearchMethodCallsCorrectEndpoint(): void + { + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode(['results' => [['title' => 'Olivia Rodrigo - SOUR']]])) + ); + + $result = $this->client->search('Olivia Rodrigo', 'release'); + + $this->assertValidSearchResponse($result); + $this->assertEquals('Olivia Rodrigo - SOUR', $result['results'][0]['title']); + } + + public function testReleaseGetMethodCallsCorrectEndpoint(): void + { + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode(['id' => 16151073, 'title' => 'Happier Than Ever'])) + ); + + $result = $this->client->getRelease(16151073); + + $this->assertEquals(['id' => 16151073, 'title' => 'Happier Than Ever'], $result); + } + + public function testMethodNameConversionWorks(): void + { + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode(['id' => '12677', 'name' => 'Interscope Records'])) + ); + + $result = $this->client->getLabel(12677); + + $this->assertEquals(['id' => '12677', 'name' => 'Interscope Records'], $result); + } + + public function testUnknownOperationThrowsException(): void + { + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Unknown operation: unknownMethod'); + + /** @noinspection PhpUndefinedMethodInspection */ + /** @phpstan-ignore-next-line */ + $this->client->unknownMethod(); + } + + public function testInvalidJsonResponseThrowsException(): void + { + $this->mockHandler->append( + new Response(200, [], 'invalid json') + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid JSON response:'); + + $this->client->getArtist(4470662); + } + + public function testApiErrorResponseThrowsException(): void + { + $this->mockHandler->append( + new Response(400, [], $this->jsonEncode([ + 'error' => 400, + 'message' => 'Bad Request: Invalid ID', + ])) + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Bad Request: Invalid ID'); + + $this->client->getArtist('invalid'); + } + + public function testApiErrorResponseWithoutMessageThrowsException(): void + { + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode([ + 'error' => 400, + // No 'message' field, should use default 'API Error' + ])) + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('API Error'); + + $this->client->getArtist(123); + } + + public function testComplexMethodNameConversion(): void + { + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode(['messages' => []])) + ); + + $result = $this->client->getMarketplaceOrderMessages('123'); + + $this->assertEquals(['messages' => []], $result); + } + + public function testCollectionItemsMethod(): void + { + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode(['releases' => []])) + ); + + $result = $this->client->listCollectionItems('user', 0); + + $this->assertEquals(['releases' => []], $result); + } + + public function testPostMethodWithJsonPayload(): void + { + $this->mockHandler->append( + new Response(201, [], $this->jsonEncode(['listing_id' => '12345'])) + ); + + $result = $this->client->createMarketplaceListing(16151073, 'Mint (M)', 25.00, 'For Sale'); + + $this->assertEquals(['listing_id' => '12345'], $result); + } + + public function testReleaseRatingGetMethod(): void + { + $this->mockHandler->append( + new Response( + 200, + [], + $this->jsonEncode(['username' => 'testuser', 'release_id' => 16151073, 'rating' => 5]) + ) + ); + + $result = $this->client->getUserReleaseRating(16151073, 'testuser'); + + $this->assertEquals(['username' => 'testuser', 'release_id' => 16151073, 'rating' => 5], $result); + } + + public function testCollectionFoldersGet(): void + { + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode([ + 'folders' => [ + ['id' => 0, 'name' => 'All', 'count' => 23], + ['id' => 1, 'name' => 'Uncategorized', 'count' => 20], + ], + ])) + ); + + $result = $this->client->listCollectionFolders('testuser'); + + $this->assertArrayHasKey('folders', $result); + $this->assertCount(2, $result['folders']); + } + + public function testWantlistGet(): void + { + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode([ + 'wants' => [ + ['id' => 28409710, 'rating' => 5, 'basic_information' => ['title' => 'Midnights']], + ], + ])) + ); + + $result = $this->client->getUserWantlist('testuser'); + + $this->assertArrayHasKey('wants', $result); + $this->assertCount(1, $result['wants']); + } + + public function testMarketplaceFeeCalculation(): void + { + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode(['value' => 0.42, 'currency' => 'USD'])) + ); + + $result = $this->client->getMarketplaceFee(10.00); + + $this->assertEquals(['value' => 0.42, 'currency' => 'USD'], $result); + } + + public function testListingGetMethod(): void + { + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode([ + 'id' => 172723812, + 'status' => 'For Sale', + 'price' => ['currency' => 'USD', 'value' => 120], + ])) + ); + + $result = $this->client->getMarketplaceListing(172723812); + + $this->assertEquals(172723812, $result['id']); + $this->assertEquals('For Sale', $result['status']); + } + + public function testUserEdit(): void + { + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode(['success' => true, 'username' => 'testuser'])) + ); + + $result = $this->client->updateUser('testuser', 'Test User', null, 'Test City'); + + $this->assertEquals(['success' => true, 'username' => 'testuser'], $result); + } + + public function testPutMethodHandling(): void + { + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode(['rating' => 5, 'release_id' => 16151073])) + ); + + $result = $this->client->updateUserReleaseRating(16151073, 'testuser', 5); + + $this->assertEquals(['rating' => 5, 'release_id' => 16151073], $result); + } + + public function testDeleteMethodHandling(): void + { + $this->mockHandler->append( + new Response(204, [], '{}') + ); + + $result = $this->client->deleteUserReleaseRating(16151073, 'testuser'); + + $this->assertEquals([], $result); + } + + public function testHttpExceptionHandling(): void + { + $this->mockHandler->append( + new RequestException( + 'Connection failed', + new Request('GET', 'test') + ) + ); + + // HTTP exceptions should pass through unchanged (lightweight approach) + $this->expectException(RequestException::class); + $this->expectExceptionMessage('Connection failed'); + + $this->client->getArtist(123); + } + + public function testNonArrayResponseHandling(): void + { + $this->mockHandler->append( + new Response(200, [], '"not an array"') + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Expected array response from API'); + + $this->client->getArtist(123); + } + + public function testUriBuilding(): void + { + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode(['id' => 123, 'name' => 'Test Artist'])) + ); + + $result = $this->client->getArtist(123); + + $this->assertEquals(['id' => 123, 'name' => 'Test Artist'], $result); + } + + public function testComplexMethodNameConversionWithMultipleParts(): void + { + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode(['messages' => []])) + ); + + $result = $this->client->addMarketplaceOrderMessage('123-456', 'Test message'); + + $this->assertEquals(['messages' => []], $result); + } + + public function testEmptyParametersHandling(): void + { + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode(['results' => []])) + ); + + // Test calling without parameters + $result = $this->client->search(); + + $this->assertEquals(['results' => []], $result); + } + + public function testConvertMethodToOperationWithEmptyString(): void + { + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode(['success' => true])) + ); + + // This will call the protected convertMethodToOperation indirectly + // by testing edge cases in method name conversion + try { + /** @noinspection PhpUndefinedMethodInspection */ + /** @phpstan-ignore-next-line */ + $this->client->testMethodName(); + } catch (RuntimeException $e) { + $this->assertStringContainsString('Unknown operation', $e->getMessage()); + } + } + + public function testBuildUriWithNoParameters(): void + { + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode(['results' => []])) + ); + + // Test URI building with no parameters (should not replace anything) + $result = $this->client->search('test'); + + $this->assertEquals(['results' => []], $result); + } + + public function testMethodCallWithNullParameters(): void + { + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode(['success' => true])) + ); + + // Test method call with null as parameters - should be converted to an empty array + /** @noinspection PhpRedundantOptionalArgumentInspection */ + $result = $this->client->search(null); + + $this->assertEquals(['success' => true], $result); + } + + /** + * @throws ReflectionException If reflection operations fail + */ + public function testConvertMethodToOperationWithEdgeCases(): void + { + // Test the convertMethodToOperation method with edge cases + $reflection = new ReflectionClass($this->client); + $method = $reflection->getMethod('convertMethodToOperation'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + // Test with empty string (should return empty string) + $result = $method->invokeArgs($this->client, ['']); + $this->assertEquals('', $result); + + // Test with a single lowercase word + $result = $method->invokeArgs($this->client, ['test']); + $this->assertEquals('test', $result); + + // Test with mixed case scenarios + $result = $method->invokeArgs($this->client, ['ArtistGetReleases']); + $this->assertEquals('ArtistGetReleases', $result); + } + + /** + * @throws ReflectionException If reflection operations fail + */ + public function testBuildUriWithComplexParameters(): void + { + // Test the buildUri method directly with complex scenarios + $reflection = new ReflectionClass($this->client); + $method = $reflection->getMethod('buildUri'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + // Test with leading slash + $result = $method->invokeArgs($this->client, ['/artists/{id}/releases', ['id' => '123']]); + $this->assertEquals('/artists/123/releases', $result); + + // Test with no parameters to replace + $result = $method->invokeArgs($this->client, ['artists', []]); + $this->assertEquals('artists', $result); + + // Test with multiple parameters + $result = $method->invokeArgs($this->client, [ + '/users/{username}/collection/folders/{folder_id}', + [ + 'username' => 'testuser', + 'folder_id' => '1', + 'extra' => 'ignored', // Should be ignored + ] + ]); + $this->assertEquals('/users/testuser/collection/folders/1', $result); + } + + /** + * @throws ReflectionException If reflection operations fail + */ + public function testPregSplitEdgeCaseHandling(): void + { + // Test case where preg_split might return false - this might be the missing line + $reflection = new ReflectionClass($this->client); + $method = $reflection->getMethod('convertMethodToOperation'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + // Test with a long method name (100 characters) to potentially trigger edge cases + $longMethodName = str_repeat('A', 100) . 'Get'; + $result = $method->invokeArgs($this->client, [$longMethodName]); + // This should still work, converting the long name properly + $this->assertIsString($result); + } + + public function testQueryParameterSeparation(): void + { + // Test the critical query parameter separation logic + $mockHandler = new MockHandler([ + new Response(200, [], '{"id": 123, "name": "Test Artist", "releases": []}') + ]); + + $handlerStack = HandlerStack::create($mockHandler); + $container = []; + $handlerStack->push(Middleware::history($container)); + + $httpClient = new Client([ + 'base_uri' => 'https://api.discogs.com/', + 'handler' => $handlerStack + ]); + $client = new DiscogsClient($httpClient); + + // Test case 1: URI parameter should NOT appear in the query string + $client->listArtistReleases(4470662, null, null, 10); + + $request = $container[0]['request']; + $this->assertEquals('/artists/4470662/releases', $request->getUri()->getPath()); + $this->assertEquals('per_page=10', $request->getUri()->getQuery()); + + // Verify that the 'id' parameter is NOT in the query (it was used in URI) + $this->assertStringNotContainsString('id=', $request->getUri()->getQuery()); + } + + public function testQueryParameterEdgeCases(): void + { + // Test edge cases for query parameter separation + $mockHandler = new MockHandler([ + new Response(200, [], '{"results": []}'), + new Response(200, [], '{"folders": []}') + ]); + + $handlerStack = HandlerStack::create($mockHandler); + $container = []; + $handlerStack->push(Middleware::history($container)); + + $httpClient = new Client([ + 'base_uri' => 'https://api.discogs.com/', + 'handler' => $handlerStack + ]); + $client = new DiscogsClient($httpClient); + + // Test case 1: No URI parameters, all should be query parameters + $client->search('Ariana Grande', 'artist'); + + $request = $container[0]['request']; + $this->assertEquals('/database/search', $request->getUri()->getPath()); + + $query = $request->getUri()->getQuery(); + $this->assertStringContainsString('q=Ariana%20Grande', $query); + $this->assertStringContainsString('type=artist', $query); + + // Test case 2: Multiple URI parameters should not appear in the query + $client->listCollectionFolders('testuser'); + + $request = $container[1]['request']; + $this->assertEquals('/users/testuser/collection/folders', $request->getUri()->getPath()); + $this->assertEquals('', $request->getUri()->getQuery()); // No query params expected + } + + public function testPreventsDuplicateParametersInUrl(): void + { + // Test the critical bug fix: prevent /artists/123?id=123 duplication + $mockHandler = new MockHandler([ + new Response(200, [], '{"id": 123, "name": "Test Artist"}'), + new Response(200, [], '{"folders": []}') + ]); + + $handlerStack = HandlerStack::create($mockHandler); + $container = []; + $handlerStack->push(Middleware::history($container)); + + $httpClient = new Client([ + 'base_uri' => 'https://api.discogs.com/', + 'handler' => $handlerStack + ]); + $client = new DiscogsClient($httpClient); + + // Test case 1: getArtist should NOT have 'id' in the query when it's in URI + $client->getArtist(4470662); + + $request = $container[0]['request']; + $this->assertEquals('/artists/4470662', $request->getUri()->getPath()); + $this->assertEquals('', $request->getUri()->getQuery()); // Should be empty! + + // Verify the critical bug fix: no duplicate id parameter + $fullUrl = (string)$request->getUri(); + $this->assertStringNotContainsString('?id=', $fullUrl); + $this->assertStringNotContainsString('&id=', $fullUrl); + + // Test case 2: listCollectionItems with mixed URI + query parameters + $client->listCollectionItems('testuser', 0, 10); + + $request = $container[1]['request']; + $this->assertEquals('/users/testuser/collection/folders/0/releases', $request->getUri()->getPath()); + $this->assertEquals('per_page=10', $request->getUri()->getQuery()); + + // Verify URI parameters don't leak into query + $query = $request->getUri()->getQuery(); + $this->assertStringNotContainsString('username=', $query); + $this->assertStringNotContainsString('folder_id=', $query); + $this->assertStringContainsString('per_page=10', $query); + } + + public function testServiceConfigurationLoading(): void + { + // Test that the service configuration is properly loaded + $client = new DiscogsClient(new Client()); + + // Use reflection to access private config + $reflection = new ReflectionClass($client); + $configProperty = $reflection->getProperty('config'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $configProperty->setAccessible(true); + $config = $configProperty->getValue($client); + + $this->assertIsArray($config); + $this->assertArrayHasKey('baseUrl', $config); + $this->assertArrayHasKey('operations', $config); + $this->assertEquals('https://api.discogs.com/', $config['baseUrl']); + } + + public function testDefaultUserAgentIsSet(): void + { + $mockHandler = new MockHandler([ + new Response(200, [], '{"id": 1}') + ]); + + $handlerStack = HandlerStack::create($mockHandler); + $container = []; + $handlerStack->push(Middleware::history($container)); + + // Create a client with array options, not GuzzleClient directly + $client = new DiscogsClient([ + 'handler' => $handlerStack + ]); + + $client->getArtist(1); + + $request = $container[0]['request']; + $userAgent = $request->getHeaderLine('User-Agent'); + + // Test that User-Agent follows an expected format (not a specific version) + $this->assertMatchesRegularExpression( + '/^DiscogsClient\/\d+\.\d+\.\d+ \+https:\/\/github\.com\/calliostro\/php-discogs-api$/', + $userAgent + ); + $this->assertNotEmpty($userAgent); + } + + public function testUserAgentComesFromConfiguration(): void + { + // Test that User-Agent is loaded from service.php, not hardcoded + $config = require __DIR__ . '/../../resources/service.php'; + $expectedUserAgent = $config['client']['options']['headers']['User-Agent']; + + $mockHandler = new MockHandler([ + new Response(200, [], '{"id": 1}') + ]); + + $handlerStack = HandlerStack::create($mockHandler); + $container = []; + $handlerStack->push(Middleware::history($container)); + + $client = new DiscogsClient([ + 'handler' => $handlerStack + ]); + + $client->getArtist(1); + + $request = $container[0]['request']; + $actualUserAgent = $request->getHeaderLine('User-Agent'); + + $this->assertEquals( + $expectedUserAgent, + $actualUserAgent, + 'User-Agent should come from service.php configuration' + ); + } + + public function testCustomUserAgentCanBeOverridden(): void + { + $mockHandler = new MockHandler([ + new Response(200, [], '{"id": 1}') + ]); + + $handlerStack = HandlerStack::create($mockHandler); + $container = []; + $handlerStack->push(Middleware::history($container)); + + // Use array options to set custom User-Agent + $client = new DiscogsClient([ + 'headers' => ['User-Agent' => 'MyCustomApp/1.0'], + 'handler' => $handlerStack + ]); + + $client->getArtist(1); + + $request = $container[0]['request']; + $userAgent = $request->getHeaderLine('User-Agent'); + $this->assertEquals('MyCustomApp/1.0', $userAgent); + } + + public function testGuzzleClientPassedDirectly(): void + { + $mockHandler = new MockHandler([ + new Response(200, [], '{"id": 123}') + ]); + + $customClient = new Client([ + 'handler' => HandlerStack::create($mockHandler), + 'timeout' => 999 // Custom option to verify it's used + ]); + + $client = new DiscogsClient($customClient); + + // Use reflection to verify the client was used directly + $reflection = new ReflectionClass($client); + $clientProperty = $reflection->getProperty('client'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $clientProperty->setAccessible(true); + $actualClient = $clientProperty->getValue($client); + + $this->assertSame($customClient, $actualClient); + } + + public function testEmptyParametersArray(): void + { + $mockHandler = new MockHandler([ + new Response(200, [], '{"results": []}') + ]); + + $client = new DiscogsClient(new Client([ + 'handler' => HandlerStack::create($mockHandler) + ])); + + // This should work without throwing exceptions + $result = $client->search(); + $this->assertIsArray($result); + } + + public function testMarketplaceEndpoints(): void + { + $mockHandler = new MockHandler([ + new Response(200, [], '{"value": 5.50}'), + new Response(200, [], '{"value": 6.50}'), + new Response(200, [], '{"suggestions": []}'), + ]); + + $handlerStack = HandlerStack::create($mockHandler); + $container = []; + $handlerStack->push(Middleware::history($container)); + + $client = new DiscogsClient(new Client([ + 'handler' => $handlerStack, + 'base_uri' => 'https://api.discogs.com/' // Explicitly set base URI for the test + ])); + + // Test marketplace fee calculation + $client->getMarketplaceFee(10.00); + $request1 = $container[0]['request']; + $this->assertEquals('https://api.discogs.com/marketplace/fee/10.00', (string)$request1->getUri()); + + // Test marketplace fee with currency + $client->getMarketplaceFeeByCurrency(10.00, 'USD'); + $request2 = $container[1]['request']; + $this->assertEquals('https://api.discogs.com/marketplace/fee/10.00/USD', (string)$request2->getUri()); + + // Test marketplace price suggestions + $client->getMarketplacePriceSuggestions(16151073); + $request3 = $container[2]['request']; + $this->assertEquals( + 'https://api.discogs.com/marketplace/price_suggestions/16151073', + (string)$request3->getUri() + ); + + // Verify no double slashes or URL typos in the path part + foreach ($container as $transaction) { + $url = (string)$transaction['request']->getUri(); + $path = parse_url($url, PHP_URL_PATH); + if ($path !== false && $path !== null) { + $this->assertStringNotContainsString('//marketplace', $path, 'Double slashes detected in URL path'); + $this->assertStringNotContainsString( + '/marketplace//', + $path, + 'Double slashes after marketplace detected' + ); + } + } + } + + public function testGetReleaseStats(): void + { + // Mock the current API response format (as of 2025) + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode(['is_offensive' => false])) + ); + + $result = $this->client->getReleaseStats(249504); + + // Verify the current API format + $this->assertEquals(['is_offensive' => false], $result); + $this->assertArrayHasKey('is_offensive', $result); + $this->assertIsBool($result['is_offensive']); + + // These keys no longer exist as of 2025 + $this->assertArrayNotHasKey('num_have', $result); + $this->assertArrayNotHasKey('num_want', $result); + $this->assertArrayNotHasKey('in_collection', $result); + $this->assertArrayNotHasKey('in_wantlist', $result); + } + + /** + * Test config file loading on the first instantiation + * This tests the config loading path via ConfigCache. + */ + public function testConfigFileLoadingOnFirstInstantiation(): void + { + // Clear the ConfigCache to force config loading + ConfigCache::clear(); + + // Create a new client - this should trigger config loading + $client = new DiscogsClient(); + /** @noinspection PhpConditionAlreadyCheckedInspection */ + $this->assertInstanceOf(DiscogsClient::class, $client); + + // Verify the config was loaded + $config = ConfigCache::get(); + $this->assertNotNull($config); + $this->assertIsArray($config); + $this->assertArrayHasKey('baseUrl', $config); + } + + /** + * Test empty response body exception (Line 204) + * This tests the uncovered empty response validation path. + */ + public function testEmptyResponseBodyThrowsException(): void + { + // Mock a client that returns an empty body + $mockResponse = $this->createMock(ResponseInterface::class); + $mockBody = $this->createMock(StreamInterface::class); + + $mockBody->expects($this->once()) + ->method('rewind'); + $mockBody->expects($this->once()) + ->method('getContents') + ->willReturn(''); // Empty content triggers Line 204 + + $mockResponse->expects($this->once()) + ->method('getBody') + ->willReturn($mockBody); + + /** @var Client&MockObject $mockClient */ + $mockClient = $this->createMock(Client::class); + $mockClient->expects($this->once()) + ->method('get') + ->willReturn($mockResponse); + + $client = new DiscogsClient($mockClient); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Empty response body received'); + + $client->getArtist(1); + } + + /** + * Test parameter name validation in buildUri method (Line 248) + * This tests the uncovered parameter validation exception path. + * @throws GuzzleException If HTTP request fails + */ + public function testBuildUriInvalidParameterNameThrowsException(): void + { + // Mock operations to force the buildUri path with invalid parameter + $mockHandler = new MockHandler([ + new Response(200, [], '{}') + ]); + + $mockClient = new Client(['handler' => HandlerStack::create($mockHandler)]); + $client = new DiscogsClient($mockClient); + + // Use reflection to set config for a URI that uses parameters + $reflection = new ReflectionClass($client); + $configProperty = $reflection->getProperty('config'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $configProperty->setAccessible(true); + + $config = $configProperty->getValue($client); + // Create operation with parameter placeholder and matching parameter config + $config['operations']['testInvalidParam'] = [ + 'httpMethod' => 'GET', + 'uri' => '/test/{invalid-param}', + 'parameters' => [ + 'invalid-param' => ['required' => true] + ] + ]; + $configProperty->setValue($client, $config); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Invalid parameter name: invalid-param'); + + // Call the method that triggers buildUri with an invalid parameter name + $client->__call('testInvalidParam', ['value']); + } + + /** + * Test network timeout scenarios (realistic edge case) + */ + public function testNetworkTimeoutHandling(): void + { + /** @var Client&MockObject $mockClient */ + $mockClient = $this->createMock(Client::class); + $mockClient->expects($this->once()) + ->method('get') + ->willThrowException( + new ConnectException( + 'cURL error 28: Connection timed out', + new Request('GET', 'https://api.discogs.com/artists/1') + ) + ); + + $client = new DiscogsClient($mockClient); + + $this->expectException(ConnectException::class); + $this->expectExceptionMessage('Connection timed out'); + + $client->getArtist(1); + } + + /** + * Test 429 Rate Limit response (very common in real usage) + */ + public function testRateLimitResponse(): void + { + $this->mockHandler->append( + new Response(429, ['Retry-After' => '60'], $this->jsonEncode([ + 'error' => 'Rate limit exceeded', + 'message' => 'You have exceeded the rate limit. Please try again in 60 seconds.' + ])) + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('You have exceeded the rate limit'); + + $this->client->getArtist(1); + } + + /** + * Test server errors (500, 502, 503) - common in production + */ + public function testServerErrorHandling(): void + { + $this->mockHandler->append( + new Response(500, [], $this->jsonEncode([ + 'error' => 'Internal Server Error', + 'message' => 'The server encountered an error' + ])) + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('The server encountered an error'); + + $this->client->getRelease(1); + } + + /** + * Test malformed JSON with special characters (realistic data corruption) + */ + public function testMalformedJsonWithSpecialCharacters(): void + { + $this->mockHandler->append( + new Response(200, [], '{"name": "Artist with \x00 null bytes", "invalid": "}') + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid JSON response'); + + $this->client->getArtist(1); + } + + /** + * Test Unicode/UTF-8 handling (realistic international data) + */ + public function testUnicodeDataHandling(): void + { + $unicodeData = [ + 'name' => 'Bjรถrk', // Nordic characters + 'label' => 'One Little Indian Records', + 'title' => 'Homogรฉnique', // French accents + 'artist' => 'ๅ‚ๆœฌ้พไธ€', // Japanese characters + 'genre' => ['ะญัั‚ั€ะฐะดะฐ'], // Cyrillic + 'notes' => '๐ŸŽต Special edition with bonus tracks ๐ŸŽต' // Emojis + ]; + + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode($unicodeData)) + ); + + $result = $this->client->getRelease(1); + + $this->assertIsArray($result); + $this->assertEquals('Bjรถrk', $result['name']); + $this->assertEquals('ๅ‚ๆœฌ้พไธ€', $result['artist']); + $this->assertStringContainsString('๐ŸŽต', $result['notes']); + } + + /** + * Test very large JSON response handling (realistic for big discographies) + */ + public function testLargeResponseHandling(): void + { + // Simulate a large response (many releases) + $releases = []; + for ($i = 0; $i < 1000; $i++) { + $releases[] = [ + 'id' => $i, + 'title' => 'Release Title ' . $i, + 'year' => 1990 + ($i % 35), + 'format' => ['Vinyl', 'LP'], + 'labels' => [['name' => 'Label ' . ($i % 50)]] + ]; + } + + $largeData = ['releases' => $releases, 'pagination' => ['items' => 1000]]; + + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode($largeData)) + ); + + $result = $this->client->getArtist(1); + + $this->assertIsArray($result); + $this->assertCount(1000, $result['releases']); + $this->assertEquals(1000, $result['pagination']['items']); + } + + /** + * Test empty string parameters (common user error) + */ + public function testEmptyStringParameters(): void + { + $this->mockHandler->append( + new Response(400, [], $this->jsonEncode([ + 'error' => 'Bad Request', + 'message' => 'Invalid ID parameter' + ])) + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid ID parameter'); + + $this->client->getArtist(''); // Empty string ID + } + + /** + * Test null parameters should throw exception for required parameters + */ + public function testNullParameterHandling(): void + { + // null should not be allowed for the required artistId parameter + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Parameter artistId is required but null was provided'); + + // @phpstan-ignore-next-line - Testing null parameter validation + $this->client->getArtist(artistId: null); + } + + /** + * Test validateRequiredParameters method - missing required parameter detection + */ + public function testValidateRequiredParametersMissingParameter(): void + { + // Test missing required parameter validation using a method with multiple parameters + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Required parameter releaseId is missing'); + + // getUserReleaseRating requires both releaseId and username - provide only username + /** @noinspection PhpParamsInspection */ + // @phpstan-ignore-next-line - Intentionally missing required parameter for test + $this->client->getUserReleaseRating(username: 'testuser'); // Missing releaseId + } + + /** + * Test validateRequiredParameters method - required parameter with null value + */ + public function testValidateRequiredParametersNullValue(): void + { + // Test that required parameters with null values throw exception + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Parameter username is required but null was provided'); + + // Call with null value for the required parameter + // @phpstan-ignore-next-line - Intentionally passing null to the required parameter for test + $this->client->getUserReleaseRating(releaseId: 123, username: null); + } + + /** + * Test validateRequiredParameters method - optional parameter with null value (should work) + */ + public function testValidateRequiredParametersOptionalNullValue(): void + { + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode(['id' => '4470662', 'name' => 'Billie Eilish'])) + ); + + // Optional parameters can be null - should not throw exception + /** @noinspection PhpRedundantOptionalArgumentInspection */ + $result = $this->client->listArtistReleases( + artistId: 4470662, + sort: null, // Optional parameter - null is allowed + sortOrder: 'desc', + perPage: 50 + ); + + $this->assertIsArray($result); + $this->assertEquals('Billie Eilish', $result['name']); + } + + /** + * Test validateRequiredParameters method - a realistic scenario with partial parameters + */ + public function testValidateRequiredParametersMultipleMissing(): void + { + // Test a realistic scenario: the user provides some required params but not all + // addToCollection needs: username, folderId, releaseId (all camelCase) + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Required parameter folderId is missing'); + + // Provide username and releaseId, but miss folderId + /** @noinspection PhpParamsInspection */ + // @phpstan-ignore-next-line - Intentionally missing required parameter for test + $this->client->addToCollection( + username: 'testuser', + releaseId: 123 + // Missing: folderId - this should trigger validation + ); + } + + /** + * Test validateRequiredParameters with non-existent operation (edge case) + */ + public function testValidateRequiredParametersNonExistentOperation(): void + { + $reflection = new ReflectionClass($this->client); + $method = $reflection->getMethod('validateRequiredParameters'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + // Should return early without throwing exception for non-existent operation + $method->invokeArgs($this->client, ['nonExistentOperation', [], []]); + + // If we reach here without exception, the test passes + $this->assertTrue(true); + } + + /** + * Test validateRequiredParameters with mixed valid/invalid parameters + */ + public function testValidateRequiredParametersMixedParameters(): void + { + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode(['username' => 'testuser', 'release_id' => 123, 'rating' => 5])) + ); + + // Valid call with all required parameters present + $result = $this->client->getUserReleaseRating( + releaseId: 123, + username: 'testuser' + ); + + $this->assertIsArray($result); + $this->assertEquals('testuser', $result['username']); + $this->assertEquals(123, $result['release_id']); + } + + /** + * Test validateRequiredParameters internal logic - missing required parameter + */ + public function testValidateRequiredParametersInternalLogicMissing(): void + { + $reflection = new ReflectionClass($this->client); + $method = $reflection->getMethod('validateRequiredParameters'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + $configProperty = $reflection->getProperty('config'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $configProperty->setAccessible(true); + $config = $configProperty->getValue($this->client); + + // Create a test operation with required and optional parameters + $config['operations']['testValidation'] = [ + 'parameters' => [ + 'required_param' => ['required' => true], + 'optional_param' => ['required' => false], + 'no_flag_param' => [] // No required flag - defaults to false + ] + ]; + $configProperty->setValue($this->client, $config); + + // Test: Missing required parameter should throw an exception + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Required parameter requiredParam is missing'); + + $method->invokeArgs($this->client, [ + 'testValidation', + ['optional_param' => 'value'], // Missing required_param + ['optionalParam' => 'value'] + ]); + } + + /** + * Test validateRequiredParameters internal logic - valid parameters + */ + public function testValidateRequiredParametersInternalLogicValid(): void + { + $reflection = new ReflectionClass($this->client); + $method = $reflection->getMethod('validateRequiredParameters'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + $configProperty = $reflection->getProperty('config'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $configProperty->setAccessible(true); + $config = $configProperty->getValue($this->client); + + // Create a test operation with required and optional parameters + $config['operations']['testValidation'] = [ + 'parameters' => [ + 'required_param' => ['required' => true], + 'optional_param' => ['required' => false], + 'no_flag_param' => [] // No required flag - defaults to false + ] + ]; + $configProperty->setValue($this->client, $config); + + // Test: All required parameters present should not throw + $method->invokeArgs($this->client, [ + 'testValidation', + [ + 'required_param' => 'value', + 'optional_param' => 'value' + ], + [ + 'requiredParam' => 'value', + 'optionalParam' => 'value' + ] + ]); + + // Test: Optional parameter with null value should be fine + $method->invokeArgs($this->client, [ + 'testValidation', + ['required_param' => 'value'], + [ + 'requiredParam' => 'value', + 'optionalParam' => null // Null is OK for optional params + ] + ]); + + // If we reach here, the validation worked correctly + $this->assertTrue(true); + } + + /** + * Test validateRequiredParameters internal logic - null value for required parameter + */ + public function testValidateRequiredParametersInternalLogicNull(): void + { + $reflection = new ReflectionClass($this->client); + $method = $reflection->getMethod('validateRequiredParameters'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + $configProperty = $reflection->getProperty('config'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $configProperty->setAccessible(true); + $config = $configProperty->getValue($this->client); + + // Create a test operation with required and optional parameters + $config['operations']['testValidation'] = [ + 'parameters' => [ + 'required_param' => ['required' => true], + 'optional_param' => ['required' => false], + ] + ]; + $configProperty->setValue($this->client, $config); + + // Test: Required parameter with null value in named args should throw + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Parameter requiredParam is required but null was provided'); + + $method->invokeArgs($this->client, [ + 'testValidation', + ['required_param' => 'value'], + ['requiredParam' => null] // Null value for required param + ]); + } + + /** + * Test DNS resolution failures (realistic network issue) + */ + public function testDnsResolutionFailure(): void + { + /** @var Client&MockObject $mockClient */ + $mockClient = $this->createMock(Client::class); + $mockClient->expects($this->once()) + ->method('get') + ->willThrowException( + new ConnectException( + 'cURL error 6: Could not resolve host', + new Request('GET', 'https://api.discogs.com/database/search') + ) + ); + + $client = new DiscogsClient($mockClient); + + $this->expectException(ConnectException::class); + $this->expectExceptionMessage('Could not resolve host'); + + $client->search('test'); + } + + /** + * Test content encoding issues (gzip/deflate corruption) + */ + public function testContentEncodingIssues(): void + { + // Simulate corrupted gzipped content + $this->mockHandler->append( + new Response(200, ['Content-Encoding' => 'gzip'], "\x1f\x8b\x08\x00\x00\x00\x00\x00\x00corrupted") + ); + + // The response will be empty after Guzzle tries to decode corrupted gzip + $mockResponse = $this->createMock(ResponseInterface::class); + $mockBody = $this->createMock(StreamInterface::class); + + $mockBody->expects($this->once())->method('rewind'); + $mockBody->expects($this->once())->method('getContents')->willReturn(''); + + $mockResponse->expects($this->once())->method('getBody')->willReturn($mockBody); + + /** @var Client&MockObject $mockClient */ + $mockClient = $this->createMock(Client::class); + $mockClient->expects($this->once())->method('get')->willReturn($mockResponse); + + $client = new DiscogsClient($mockClient); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Empty response body received'); + + $client->getArtist(1); + } + + /** + * Test upload parameter handling with a string type + */ + public function testAddInventoryUploadWithStringParameter(): void + { + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode(['success' => true, 'message' => 'Upload successful'])) + ); + + // Test with string (CSV content as string) + $csvContent = "Release ID,Condition,Price\n1234,Mint (M),15.99\n5678,Very Good+ (VG+),8.50"; + $result = $this->client->addInventoryUpload($csvContent); + + $this->assertIsArray($result); + $this->assertTrue($result['success']); + $this->assertEquals('Upload successful', $result['message']); + } + + /** + * Test changeInventoryUpload parameter handling with string + */ + public function testChangeInventoryUploadWithStringParameter(): void + { + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode(['success' => true, 'updated' => 2])) + ); + + $csvContent = "Release ID,Condition,Price\n1234,Near Mint (NM),18.99"; + $result = $this->client->changeInventoryUpload($csvContent); + + $this->assertIsArray($result); + $this->assertTrue($result['success']); + $this->assertEquals(2, $result['updated']); + } + + /** + * Test deleteInventoryUpload endpoint (takes CSV upload file) + */ + public function testDeleteInventoryUpload(): void + { + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode(['success' => true, 'message' => 'Upload deleted'])) + ); + + // deleteInventoryUpload takes a CSV file with a listing_id column + $csvContent = "listing_id\n12345678\n98765432"; + $result = $this->client->deleteInventoryUpload($csvContent); + + $this->assertIsArray($result); + $this->assertTrue($result['success']); + $this->assertEquals('Upload deleted', $result['message']); + } + + /** + * Test upload methods with correct endpoints + */ + public function testUploadMethodsWithCorrectEndpoints(): void + { + $mockHandler = new MockHandler([ + new Response(200, [], '{"success": true}'), + new Response(200, [], '{"success": true}'), + new Response(200, [], '{"success": true}') + ]); + + $handlerStack = HandlerStack::create($mockHandler); + $container = []; + $handlerStack->push(Middleware::history($container)); + + $client = new DiscogsClient(new Client([ + 'handler' => $handlerStack, + 'base_uri' => 'https://api.discogs.com/' + ])); + + // Test the three upload methods + $addCsv = "release_id,price,media_condition\n1234,15.99,Mint (M)"; + $changeCsv = "release_id,price\n1234,18.99"; + $deleteCsv = "listing_id\n12345678"; + + $client->addInventoryUpload($addCsv); + $client->changeInventoryUpload($changeCsv); + $client->deleteInventoryUpload($deleteCsv); + + // Verify all requests were made to correct endpoints + $this->assertCount(3, $container); + + $request1 = $container[0]['request']; + $request2 = $container[1]['request']; + $request3 = $container[2]['request']; + + $this->assertEquals('/inventory/upload/add', $request1->getUri()->getPath()); + $this->assertEquals('/inventory/upload/change', $request2->getUri()->getPath()); + $this->assertEquals('/inventory/upload/delete', $request3->getUri()->getPath()); + } + + /** + * Test convertCamelToSnake method with comprehensive edge cases + */ + public function testConvertCamelToSnake(): void + { + $reflection = new ReflectionClass($this->client); + $method = $reflection->getMethod('convertCamelToSnake'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + // Test empty string + $result = $method->invokeArgs($this->client, ['']); + $this->assertEquals('', $result); + + // Test a single lowercase word + $result = $method->invokeArgs($this->client, ['test']); + $this->assertEquals('test', $result); + + // Test a simple camelCase + $result = $method->invokeArgs($this->client, ['testCase']); + $this->assertEquals('test_case', $result); + + // Test multiple capitals + $result = $method->invokeArgs($this->client, ['getMySpecialId']); + $this->assertEquals('get_my_special_id', $result); + + // Test with numbers (regex only matches letter-to-letter transitions) + $result = $method->invokeArgs($this->client, ['test2Case']); + $this->assertEquals('test2case', $result); + + // Test consecutive capitals (regex matches each lowercase-to-uppercase transition) + $result = $method->invokeArgs($this->client, ['getHTMLParser']); + $this->assertEquals('get_htmlparser', $result); + + // Test a single capital letter + $result = $method->invokeArgs($this->client, ['A']); + $this->assertEquals('a', $result); + + // Test mixed cases + $result = $method->invokeArgs($this->client, ['userId']); + $this->assertEquals('user_id', $result); + + // Test real-world examples + $result = $method->invokeArgs($this->client, ['folderId']); + $this->assertEquals('folder_id', $result); + + $result = $method->invokeArgs($this->client, ['releaseTitle']); + $this->assertEquals('release_title', $result); + } + + /** + * Test parameter conversion utility method with different parameter types + */ + public function testParameterConversionWithBasicTypes(): void + { + // Test the convertParameterToString method with basic types + $reflection = new ReflectionClass($this->client); + $method = $reflection->getMethod('convertParameterToString'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + // Test string parameter + $result = $method->invokeArgs($this->client, ['test string']); + $this->assertEquals('test string', $result); + + // Test numeric parameter (should be converted to string) + $result = $method->invokeArgs($this->client, [12345]); + $this->assertEquals('12345', $result); + + // Test array parameter (should be JSON encoded) + $result = $method->invokeArgs($this->client, [['key' => 'value']]); + $this->assertEquals('{"key":"value"}', $result); + + // Test boolean parameters + $result = $method->invokeArgs($this->client, [true]); + $this->assertEquals('1', $result); + + $result = $method->invokeArgs($this->client, [false]); + $this->assertEquals('0', $result); + + // Test null parameter + $result = $method->invokeArgs($this->client, [null]); + $this->assertEquals('', $result); + + // Test float parameter (should be formatted to 2 decimal places) + $result = $method->invokeArgs($this->client, [123.456]); + $this->assertEquals('123.46', $result); + + $result = $method->invokeArgs($this->client, [10.0]); + $this->assertEquals('10.00', $result); + + // Test DateTime object + $date = new \DateTime('2025-09-12T10:30:00+00:00'); + $result = $method->invokeArgs($this->client, [$date]); + $this->assertEquals('2025-09-12T10:30:00+00:00', $result); + + // Test DateTimeImmutable object + $dateImmutable = new \DateTimeImmutable('2025-12-25T15:45:30+00:00'); + $result = $method->invokeArgs($this->client, [$dateImmutable]); + $this->assertEquals('2025-12-25T15:45:30+00:00', $result); + + // Test object with __toString method + $stringableObject = new class () { + public function __toString(): string + { + return 'stringable object'; + } + }; + $result = $method->invokeArgs($this->client, [$stringableObject]); + $this->assertEquals('stringable object', $result); + + // Test complex array (should be JSON encoded) + $complexArray = [ + 'nested' => ['key' => 'value'], + 'array' => [1, 2, 3], + 'mixed' => true + ]; + $result = $method->invokeArgs($this->client, [$complexArray]); + $this->assertEquals('{"nested":{"key":"value"},"array":[1,2,3],"mixed":true}', $result); + } + + /** + * Test convertParameterToString error conditions + */ + public function testConvertParameterToStringErrorConditions(): void + { + $reflection = new ReflectionClass($this->client); + $method = $reflection->getMethod('convertParameterToString'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + // Test object without __toString method + $plainObject = new \stdClass(); + $plainObject->prop = 'value'; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Object parameters must implement __toString() method or be DateTime instances'); + $method->invokeArgs($this->client, [$plainObject]); + } + + /** + * Test convertParameterToString with a resource type (unsupported) + */ + public function testConvertParameterToStringUnsupportedType(): void + { + $reflection = new ReflectionClass($this->client); + $method = $reflection->getMethod('convertParameterToString'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + // Test resource (unsupported type) + $resource = fopen('php://memory', 'r'); + $this->assertIsResource($resource, 'Failed to create resource for test'); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Unsupported parameter type: resource'); + + try { + $method->invokeArgs($this->client, [$resource]); + } finally { + if (is_resource($resource)) { + fclose($resource); + } + } + } + + /** + * Test buildParamsFromArguments method with comprehensive scenarios + */ + public function testBuildParamsFromArguments(): void + { + $reflection = new ReflectionClass($this->client); + $method = $reflection->getMethod('buildParamsFromArguments'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + // Test with empty arguments + $result = $method->invokeArgs($this->client, ['getArtist', []]); + $this->assertEquals([], $result); + + // Test with an unknown method (no operation config) + $result = $method->invokeArgs($this->client, ['unknownMethod', [123]]); + $this->assertEquals([], $result); + + // Test positional parameters - getArtist expects artist_id + $result = $method->invokeArgs($this->client, ['getArtist', [139250]]); + $this->assertEquals(['artist_id' => 139250], $result); + + // Test multiple positional parameters - listArtistReleases + $result = $method->invokeArgs($this->client, ['listArtistReleases', [139250, 'year', 'desc', 50, 1]]); + $this->assertEquals([ + 'artist_id' => 139250, + 'sort' => 'year', + 'sort_order' => 'desc', + 'per_page' => 50, + 'page' => 1 + ], $result); + + // Test named parameters with camelCase (only camelCase allowed now) + $result = $method->invokeArgs($this->client, ['getArtist', ['artistId' => 139250]]); + $this->assertEquals(['artist_id' => 139250], $result); + + // Test mixed named parameters + $result = $method->invokeArgs($this->client, [ + 'listArtistReleases', + [ + 'artistId' => 139250, + 'sort' => 'year', + 'sortOrder' => 'desc' + ] + ]); + $this->assertEquals([ + 'artist_id' => 139250, + 'sort' => 'year', + 'sort_order' => 'desc' + ], $result); + + // Test parameter overflow - more positional params than expected + $result = $method->invokeArgs($this->client, ['getArtist', [139250, 'extra', 'params']]); + $this->assertEquals(['artist_id' => 139250], $result); // Only the first param mapped + } + + /** + * Test preg_replace error handling in convertCamelToSnake + */ + public function testConvertCamelToSnakePregReplaceError(): void + { + // Test with a pattern that might cause preg_replace to return null + // This is an edge case, but we need 100% coverage + $reflection = new ReflectionClass($this->client); + $method = $reflection->getMethod('convertCamelToSnake'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + // Test with extremely long string that might cause PCRE errors + $veryLongString = str_repeat('camelCaseParameter', 1000); // 17000+ characters + $result = $method->invokeArgs($this->client, [$veryLongString]); + + // Should still return a string (either converted or original) + $this->assertIsString($result); + $this->assertNotEmpty($result); + } + + /** + * Test those unknown named parameters throw Error (PHP-native behavior) + */ + public function testBuildParamsFromArgumentsThrowsErrorForUnknownParameters(): void + { + $reflection = new ReflectionClass($this->client); + $method = $reflection->getMethod('buildParamsFromArguments'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + // Test named parameter that doesn't match any expected parameter + // Should throw Error like PHP's native named parameter behavior + $this->expectException(\Error::class); + $this->expectExceptionMessage('Unknown named parameter $unknown_param'); + $method->invokeArgs($this->client, [ + 'getArtist', + [ + 'artistId' => 139250, // Use camelCase (allowed) + 'unknown_param' => 'value' // This should trigger the error + ] + ]); + } + + /** + * Test getAllowedCamelCaseParams with an edge case + */ + public function testGetAllowedCamelCaseParamsEdgeCase(): void + { + $reflection = new ReflectionClass($this->client); + $method = $reflection->getMethod('getAllowedCamelCaseParams'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + // Test with an operation that doesn't exist + $result = $method->invokeArgs($this->client, ['nonExistentOperation']); + $this->assertEquals([], $result); + + // Test normal operation + $result = $method->invokeArgs($this->client, ['getArtist']); + $this->assertContains('artistId', $result); + } + + /** + * Test validateRequiredParameters with an edge case + */ + public function testValidateRequiredParametersEdgeCase(): void + { + $reflection = new ReflectionClass($this->client); + $method = $reflection->getMethod('validateRequiredParameters'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + // Test with an operation that has no parameters configured + $method->invokeArgs($this->client, ['nonExistentOperation', [], []]); + + // Should not throw exception - just return early + $this->assertTrue(true); + } + + /** + * Test JSON encoding error in convertParameterToString with circular reference + */ + public function testConvertParameterToStringJsonErrorCircular(): void + { + $reflection = new ReflectionClass($this->client); + $method = $reflection->getMethod('convertParameterToString'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + // Create a circular reference that can't be JSON encoded + $circularArray = []; + $circularArray['self'] = &$circularArray; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Failed to encode array parameter as JSON: Recursion detected'); + $method->invokeArgs($this->client, [$circularArray]); + } + + /** + * Test JSON encoding error with infinity/NaN values + */ + public function testConvertParameterToStringJsonErrorInfinity(): void + { + $reflection = new ReflectionClass($this->client); + $method = $reflection->getMethod('convertParameterToString'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + // Array with infinity value that can't be JSON encoded + $infinityArray = ['value' => INF]; + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Failed to encode array parameter as JSON: Inf and NaN cannot be JSON encoded'); + $method->invokeArgs($this->client, [$infinityArray]); + } + + /** + * Test convertSnakeToCamel with edge cases + */ + public function testConvertSnakeToCamelEdgeCases(): void + { + $reflection = new ReflectionClass($this->client); + $method = $reflection->getMethod('convertSnakeToCamel'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + // Test empty string + $result = $method->invokeArgs($this->client, ['']); + $this->assertEquals('', $result); + + // Test string with no underscores + $result = $method->invokeArgs($this->client, ['nounderscores']); + $this->assertEquals('nounderscores', $result); + + // Test multiple consecutive underscores + $result = $method->invokeArgs($this->client, ['test__double__underscore']); + $this->assertEquals('testDoubleUnderscore', $result); + } + + /** + * Test buildUri with a parameter that doesn't exist in the URI template + */ + public function testBuildUriWithUnusedParameters(): void + { + $reflection = new ReflectionClass($this->client); + $method = $reflection->getMethod('buildUri'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + // Test with a parameter that's not in the URI template + $result = $method->invokeArgs($this->client, ['/artists/{id}', ['id' => '123', 'unused_param' => 'ignored']]); + $this->assertEquals('/artists/123', $result); + } + + /** + * Test mixed positional and named parameters edge case + */ + public function testBuildParamsFromArgumentsMixedParameters(): void + { + $reflection = new ReflectionClass($this->client); + $method = $reflection->getMethod('buildParamsFromArguments'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + // Test with an array that has both numeric and string keys (mixed) + $arguments = [0 => 'positional', 'artistId' => 139250]; + + // This should be treated as associative because it has string keys + $result = $method->invokeArgs($this->client, ['getArtist', $arguments]); + + // Should process as named parameters, ignore positional + $this->assertEquals(['artist_id' => 139250], $result); + } + + /** + * Test specific configuration edge case to trigger is_string() check + */ + public function testGetAllowedCamelCaseParamsWithNonStringKeys(): void + { + // Create a mock client with modified config to test is_string check + $reflection = new ReflectionClass($this->client); + $configProperty = $reflection->getProperty('config'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $configProperty->setAccessible(true); + + $config = $configProperty->getValue($this->client); + + // Create a test operation with non-string parameter keys (edge case) + $config['operations']['testOperation'] = [ + 'httpMethod' => 'GET', + 'uri' => '/test', + 'parameters' => [ + 'valid_param' => ['required' => true], + 123 => ['required' => false], // Numeric key - shouldn't be processed + ] + ]; + + $configProperty->setValue($this->client, $config); + + $method = $reflection->getMethod('getAllowedCamelCaseParams'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + $result = $method->invokeArgs($this->client, ['testOperation']); + + // Should only include the string parameter key + $this->assertEquals(['validParam'], $result); + } + + /** + * Test validateRequiredParameters with a parameter that has no 'required' key + */ + public function testValidateRequiredParametersWithoutRequiredKey(): void + { + $reflection = new ReflectionClass($this->client); + $method = $reflection->getMethod('validateRequiredParameters'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + $configProperty = $reflection->getProperty('config'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $configProperty->setAccessible(true); + $config = $configProperty->getValue($this->client); + + // Create operation where parameter exists but has no 'required' key (defaults to false) + $config['operations']['testNoRequired'] = [ + 'parameters' => [ + 'test_param' => [] // No 'required' flag - should default to false + ] + ]; + $configProperty->setValue($this->client, $config); + + // This should NOT throw an exception because 'required' defaults to false + $method->invokeArgs($this->client, ['testNoRequired', [], ['testParam' => null]]); + + // If we get here without exception, the test passes + $this->assertTrue(true); + } + + /** + * Test final edge case - absolutely comprehensive coverage attempt + */ + public function testFinalCoverageEdgeCases(): void + { + $reflection = new ReflectionClass($this->client); + + // Test convertCamelToSnake with edge case that might cause preg_replace to behave differently + $convertMethod = $reflection->getMethod('convertCamelToSnake'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $convertMethod->setAccessible(true); + + // Test with a string that has multiple patterns + $result = $convertMethod->invokeArgs($this->client, ['TestABCDef']); + $this->assertIsString($result); + + // Test with an empty result from operation config + $getAllowedMethod = $reflection->getMethod('getAllowedCamelCaseParams'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $getAllowedMethod->setAccessible(true); + + $result = $getAllowedMethod->invokeArgs($this->client, ['totallyUnknownOperation']); + $this->assertEquals([], $result); + } + + /** + * Test the two uncovered lines in callOperation - URI length validation + */ + public function testCallOperationUriLengthValidation(): void + { + $reflection = new ReflectionClass($this->client); + $configProperty = $reflection->getProperty('config'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $configProperty->setAccessible(true); + + $config = $configProperty->getValue($this->client); + + // Create a test operation with extremely long URI (> 2048 characters) to trigger the validation + $longUri = str_repeat('/very-long-path-segment', 100) . '{id}'; // Creates > 2048 chars + $config['operations']['testLongUri'] = [ + 'httpMethod' => 'GET', + 'uri' => $longUri + ]; + + $configProperty->setValue($this->client, $config); + + $callOperationMethod = $reflection->getMethod('callOperation'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $callOperationMethod->setAccessible(true); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('URI too long'); + + $callOperationMethod->invokeArgs($this->client, ['testLongUri', ['id' => '123']]); + } + + /** + * Test the second uncovered line - too many placeholders validation + */ + public function testCallOperationTooManyPlaceholders(): void + { + $reflection = new ReflectionClass($this->client); + $configProperty = $reflection->getProperty('config'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $configProperty->setAccessible(true); + + $config = $configProperty->getValue($this->client); + + // Create a test operation with > 50 placeholders to trigger the validation + $placeholders = []; + for ($i = 1; $i <= 55; $i++) { + $placeholders[] = '{param' . $i . '}'; + } + $uriWithManyPlaceholders = '/test/' . implode('/', $placeholders); + + $config['operations']['testManyPlaceholders'] = [ + 'httpMethod' => 'GET', + 'uri' => $uriWithManyPlaceholders + ]; + + $configProperty->setValue($this->client, $config); + + $callOperationMethod = $reflection->getMethod('callOperation'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $callOperationMethod->setAccessible(true); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Too many placeholders in URI'); + + $callOperationMethod->invokeArgs($this->client, ['testManyPlaceholders', []]); + } + + protected function setUp(): void + { + $this->mockHandler = new MockHandler(); + $handlerStack = HandlerStack::create($this->mockHandler); + $guzzleClient = new Client(['handler' => $handlerStack]); + + $this->client = new DiscogsClient($guzzleClient); + } +} + +/** + * Remove the extended test class - it wasn't being recognized + */ diff --git a/tests/Unit/HeaderSecurityTest.php b/tests/Unit/HeaderSecurityTest.php new file mode 100644 index 0000000..71ed851 --- /dev/null +++ b/tests/Unit/HeaderSecurityTest.php @@ -0,0 +1,124 @@ +push(Middleware::history($history)); + + // User tries to override Authorization header + $client = DiscogsClientFactory::createWithPersonalAccessToken( + 'token789', + [ + 'handler' => $handlerStack, + 'headers' => [ + 'Authorization' => 'Bearer malicious-token', + 'User-Agent' => 'MyApp/1.0', + 'X-Custom' => 'custom-value' + ] + ] + ); + + $client->search('test'); + + $request = $history[0]['request']; + + $authHeader = $request->getHeaderLine('Authorization'); + $this->assertValidPersonalTokenHeader($authHeader); + $this->assertStringContainsString('token789', $authHeader); + $this->assertStringNotContainsString('Bearer malicious-token', $authHeader); + $this->assertSame('MyApp/1.0', $request->getHeaderLine('User-Agent')); + $this->assertSame('custom-value', $request->getHeaderLine('X-Custom')); + } + + /** + * @throws Exception If test setup or execution fails + */ + public function testUserCannotOverrideAuthorizationWithOAuth(): void + { + $mock = new MockHandler([ + new Response(200, [], '{"identity": {"username": "testuser"}}') + ]); + + $handlerStack = HandlerStack::create($mock); + $history = []; + $handlerStack->push(Middleware::history($history)); + + // User tries to override Authorization header + $client = DiscogsClientFactory::createWithOAuth( + 'key123', + 'secret456', + 'token789', + 'tokensecret', + [ + 'handler' => $handlerStack, + 'headers' => [ + 'Authorization' => 'Basic malicious-credentials', + 'Accept' => 'application/json', + ] + ] + ); + + $client->getIdentity(); + + $request = $history[0]['request']; + + $authHeader = $request->getHeaderLine('Authorization'); + $this->assertValidOAuthHeader($authHeader); + $this->assertStringNotContainsString('Basic malicious-credentials', $authHeader); + $this->assertEquals('application/json', $request->getHeaderLine('Accept')); + } + + public function testUserCanSetCustomHeadersWithoutConflicts(): void + { + $mock = new MockHandler([ + new Response(200, [], '{"results": []}') + ]); + + $handlerStack = HandlerStack::create($mock); + $history = []; + $handlerStack->push(Middleware::history($history)); + + $client = DiscogsClientFactory::createWithPersonalAccessToken( + 'token789', + [ + 'handler' => $handlerStack, + 'headers' => [ + 'User-Agent' => 'CustomApp/2.0', + 'Accept' => 'application/json', + 'X-API-Version' => 'v2', + 'Cache-Control' => 'no-cache' + ] + ] + ); + + $client->search('test'); + + $request = $history[0]['request']; + + $authHeader = $request->getHeaderLine('Authorization'); + $this->assertValidPersonalTokenHeader($authHeader); + $this->assertStringContainsString('token789', $authHeader); + $this->assertSame('CustomApp/2.0', $request->getHeaderLine('User-Agent')); + $this->assertEquals('application/json', $request->getHeaderLine('Accept')); + $this->assertEquals('v2', $request->getHeaderLine('X-API-Version')); + $this->assertEquals('no-cache', $request->getHeaderLine('Cache-Control')); + } +} diff --git a/tests/Unit/OAuthHelperTest.php b/tests/Unit/OAuthHelperTest.php new file mode 100644 index 0000000..37c2ac6 --- /dev/null +++ b/tests/Unit/OAuthHelperTest.php @@ -0,0 +1,204 @@ +getAuthorizationUrl('request_token'); + + $this->assertSame( + 'https://discogs.com/oauth/authorize?oauth_token=request_token', + $url + ); + } + + /** + * @throws GuzzleException If HTTP request fails + */ + public function testGetRequestTokenSuccess(): void + { + $mockHandler = new MockHandler([ + new Response( + 200, + [], + 'oauth_token=request_token&oauth_token_secret=request_secret&oauth_callback_confirmed=true' + ) + ]); + + $handlerStack = HandlerStack::create($mockHandler); + $guzzleClient = new Client(['handler' => $handlerStack]); + + $helper = new OAuthHelper($guzzleClient); + $result = $helper->getRequestToken('consumer_key', 'consumer_secret', 'https://callback.url'); + + $this->assertSame('request_token', $result['oauth_token']); + $this->assertSame('request_secret', $result['oauth_token_secret']); + $this->assertSame('true', $result['oauth_callback_confirmed']); + } + + /** + * @throws GuzzleException If HTTP request fails + */ + public function testGetRequestTokenValidatesResponse(): void + { + $mockHandler = new MockHandler([ + new Response(200, [], 'invalid_response=true') + ]); + + $handlerStack = HandlerStack::create($mockHandler); + $guzzleClient = new Client(['handler' => $handlerStack]); + + $helper = new OAuthHelper($guzzleClient); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid OAuth request token response'); + + $helper->getRequestToken('consumer_key', 'consumer_secret', 'https://callback.url'); + } + + /** + * @throws GuzzleException If HTTP request fails + */ + public function testGetAccessTokenValidatesResponse(): void + { + $mockHandler = new MockHandler([ + new Response(200, [], 'invalid_response=true') + ]); + + $handlerStack = HandlerStack::create($mockHandler); + $guzzleClient = new Client(['handler' => $handlerStack]); + + $helper = new OAuthHelper($guzzleClient); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid OAuth access token response'); + + $helper->getAccessToken('consumer_key', 'consumer_secret', 'request_token', 'request_secret', 'verifier'); + } + + /** + * @throws GuzzleException If HTTP request fails + */ + public function testGetAccessTokenSuccess(): void + { + $mockHandler = new MockHandler([ + new Response(200, [], 'oauth_token=access_token&oauth_token_secret=access_secret') + ]); + + $handlerStack = HandlerStack::create($mockHandler); + $guzzleClient = new Client(['handler' => $handlerStack]); + + $helper = new OAuthHelper($guzzleClient); + $result = $helper->getAccessToken( + 'consumer_key', + 'consumer_secret', + 'request_token', + 'request_secret', + 'verifier' + ); + + $this->assertSame('access_token', $result['oauth_token']); + $this->assertSame('access_secret', $result['oauth_token_secret']); + } + + /** + * @throws GuzzleException If HTTP request fails + */ + public function testGetRequestTokenHandlesGuzzleException(): void + { + $mockHandler = new MockHandler([ + new ServerException('Server Error', new Request('GET', 'test'), new Response(500)) + ]); + + $handlerStack = HandlerStack::create($mockHandler); + $guzzleClient = new Client(['handler' => $handlerStack]); + + $helper = new OAuthHelper($guzzleClient); + + $this->expectException(ServerException::class); + $this->expectExceptionMessage('Server Error'); + + $helper->getRequestToken('consumer_key', 'consumer_secret', 'https://callback.url'); + } + + /** + * @throws GuzzleException If HTTP request fails + */ + public function testGetAccessTokenHandlesGuzzleException(): void + { + $mockHandler = new MockHandler([ + new ServerException('Server Error', new Request('GET', 'test'), new Response(500)) + ]); + + $handlerStack = HandlerStack::create($mockHandler); + $guzzleClient = new Client(['handler' => $handlerStack]); + + $helper = new OAuthHelper($guzzleClient); + + $this->expectException(ServerException::class); + $this->expectExceptionMessage('Server Error'); + + $helper->getAccessToken('consumer_key', 'consumer_secret', 'request_token', 'request_secret', 'verifier'); + } + + /** + * @throws GuzzleException If HTTP request fails + */ + public function testGetRequestTokenHandlesNonStringCallbackConfirmed(): void + { + $mockHandler = new MockHandler([ + new Response( + 200, + [], + 'oauth_token=request_token&oauth_token_secret=request_secret&oauth_callback_confirmed[]=array' + ) + ]); + + $handlerStack = HandlerStack::create($mockHandler); + $guzzleClient = new Client(['handler' => $handlerStack]); + + $helper = new OAuthHelper($guzzleClient); + $result = $helper->getRequestToken('consumer_key', 'consumer_secret', 'https://callback.url'); + + $this->assertSame('request_token', $result['oauth_token']); + $this->assertSame('request_secret', $result['oauth_token_secret']); + $this->assertSame('false', $result['oauth_callback_confirmed']); // Defaults to 'false' + } + + /** + * @throws GuzzleException If HTTP request fails + */ + public function testGetRequestTokenHandlesMissingCallbackConfirmed(): void + { + $mockHandler = new MockHandler([ + new Response(200, [], 'oauth_token=request_token&oauth_token_secret=request_secret') + ]); + + $handlerStack = HandlerStack::create($mockHandler); + $guzzleClient = new Client(['handler' => $handlerStack]); + + $helper = new OAuthHelper($guzzleClient); + $result = $helper->getRequestToken('consumer_key', 'consumer_secret', 'https://callback.url'); + + $this->assertSame('request_token', $result['oauth_token']); + $this->assertSame('request_secret', $result['oauth_token_secret']); + $this->assertSame('false', $result['oauth_callback_confirmed']); // Defaults to 'false' + } +} diff --git a/tests/Unit/ProductionRealisticTest.php b/tests/Unit/ProductionRealisticTest.php new file mode 100644 index 0000000..b94b59e --- /dev/null +++ b/tests/Unit/ProductionRealisticTest.php @@ -0,0 +1,261 @@ +mockHandler->append( + new Response(502, [], '

502 Bad Gateway

') + ); + + // Guzzle throws ServerException for 5xx responses + $this->expectException(ServerException::class); + $this->expectExceptionMessage('502 Bad Gateway'); + + $this->client->getArtist(1); + } + + /** + * Test 503 Service Unavailable with Retry-After + */ + public function testServiceUnavailableWithRetryAfter(): void + { + $this->mockHandler->append( + new Response(503, ['Retry-After' => '120'], $this->jsonEncode([ + 'error' => 'Service Unavailable', + 'message' => 'The service is temporarily unavailable. Please try again later.' + ])) + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('temporarily unavailable'); + + $this->client->search('Dua Lipa'); + } + + + /** + * Test CloudFlare errors (very common in production) + */ + public function testCloudFlareError(): void + { + $this->mockHandler->append( + new Response(524, [], $this->jsonEncode([ + 'error' => 524, + 'message' => 'A timeout occurred', + 'description' => 'CloudFlare: The origin web server timed out responding to this request.' + ])) + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('A timeout occurred'); + + $this->client->getRelease(1); + } + + /** + * Test a very long response time (simulated timeout) + */ + public function testVerySlowResponse(): void + { + // Simulate a request that takes too long + /** @var Client&MockObject $mockClient */ + $mockClient = $this->createMock(Client::class); + $mockClient->expects($this->once()) + ->method('get') + ->willThrowException( + new RequestException( + 'cURL error 28: Operation timed out after 30000 milliseconds', + new Request('GET', 'https://api.discogs.com/artists/1') + ) + ); + + $client = new DiscogsClient($mockClient); + + $this->expectException(RequestException::class); + $this->expectExceptionMessage('Operation timed out'); + + $client->getArtist(1); + } + + /** + * Test SSL certificate issues (common in dev/staging) + */ + public function testSslCertificateError(): void + { + /** @var Client&MockObject $mockClient */ + $mockClient = $this->createMock(Client::class); + $mockClient->expects($this->once()) + ->method('get') + ->willThrowException( + new ConnectException( + 'cURL error 60: SSL certificate problem: unable to get local issuer certificate', + new Request('GET', 'https://api.discogs.com/artists/1') + ) + ); + + $client = new DiscogsClient($mockClient); + + $this->expectException(ConnectException::class); + $this->expectExceptionMessage('SSL certificate problem'); + + $client->getArtist(1); + } + + /** + * Test API returning partial/truncated JSON (network issues) + */ + public function testPartialJsonResponse(): void + { + // Truncated JSON response (network interrupted) + $this->mockHandler->append( + new Response(200, [], '{"id": 1, "name": "Ar') + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid JSON response'); + + $this->client->getArtist(1); + } + + /** + * Test extremely large ID numbers (edge of int limits) + */ + public function testExtremelyLargeIds(): void + { + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode(['id' => 999999999999, 'name' => 'Test Artist'])) + ); + + $result = $this->client->getArtist(999999999999); + + $this->assertIsArray($result); + $this->assertEquals(999999999999, $result['id']); + } + + /** + * Test special characters in search queries (user-input edge case) + */ + public function testSpecialCharactersInSearch(): void + { + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode(['results' => []])) + ); + + // Test with problematic characters that might break URL encoding + $result = $this->client->search('Post Malone: Hollywood\'s Bleeding [Deluxe]'); + + $this->assertIsArray($result); + $this->assertArrayHasKey('results', $result); + } + + /** + * Test API maintenance mode response + */ + public function testApiMaintenanceMode(): void + { + $this->mockHandler->append( + new Response(503, ['Retry-After' => '3600'], $this->jsonEncode([ + 'error' => 'Maintenance Mode', + 'message' => 'The API is currently undergoing scheduled maintenance. Please try again in 1 hour.', + 'maintenance_end' => '2025-09-10T18:00:00Z' + ])) + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('scheduled maintenance'); + + $this->client->getArtist(1); + } + + /** + * Test JSON response with deeply nested structures (memory stress test) + */ + public function testDeeplyNestedJsonResponse(): void + { + // Create deeply nested structure + $nested = ['value' => 'deep']; + for ($i = 0; $i < 100; $i++) { + $nested = ['level' . $i => $nested]; + } + + $this->mockHandler->append( + new Response(200, [], $this->jsonEncode(['data' => $nested])) + ); + + $result = $this->client->getArtist(1); + + $this->assertIsArray($result); + $this->assertArrayHasKey('data', $result); + } + + /** + * Test response with BOM (Byte Order Mark) - encoding issue + */ + public function testResponseWithBom(): void + { + // UTF-8 BOM + JSON + $jsonWithBom = "\xEF\xBB\xBF" . $this->jsonEncode(['id' => 1, 'name' => 'Test Artist']); + + $this->mockHandler->append( + new Response(200, [], $jsonWithBom) + ); + + $this->expectException(RuntimeException::class); + $this->expectExceptionMessage('Invalid JSON response'); + + $this->client->getArtist(1); + } + + /** + * Test API returning HTML error page instead of JSON (misconfiguration) + */ + public function testHtmlErrorPageResponse(): void + { + $htmlError = 'Error

Internal Server Error

'; + + $this->mockHandler->append( + new Response(500, ['Content-Type' => 'text/html'], $htmlError) + ); + + // Guzzle throws ServerException for 5xx responses + $this->expectException(ServerException::class); + $this->expectExceptionMessage('500 Internal Server Error'); + + $this->client->getArtist(1); + } + + protected function setUp(): void + { + $this->mockHandler = new MockHandler(); + $handlerStack = HandlerStack::create($this->mockHandler); + $guzzleClient = new Client(['handler' => $handlerStack]); + $this->client = new DiscogsClient($guzzleClient); + } +} diff --git a/tests/Unit/SecurityTest.php b/tests/Unit/SecurityTest.php new file mode 100644 index 0000000..f7c68aa --- /dev/null +++ b/tests/Unit/SecurityTest.php @@ -0,0 +1,213 @@ +jsonEncode(['id' => 123])) + ]); + + $handlerStack = HandlerStack::create($mockHandler); + $client = new DiscogsClient(['handler' => $handlerStack]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('URI too long'); + + // Create a very long URI to trigger ReDoS protection + $longUri = str_repeat('a', 2049); + + // We need to use reflection to test the internal buildUri method with a crafted operation + $reflection = new ReflectionClass($client); + $property = $reflection->getProperty('config'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $property->setAccessible(true); + + $config = $property->getValue($client); + $config['operations']['testLongUri'] = [ + 'httpMethod' => 'GET', + 'uri' => $longUri + ]; + $property->setValue($client, $config); + + // Use reflection to call the operation via the magic __call method + $reflection = new ReflectionClass($client); + $method = $reflection->getMethod('__call'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + // This should trigger the ReDoS protection + $method->invoke($client, 'testLongUri', [['id' => '123']]); + } + + + /** + * @throws ReflectionException If reflection operations fail + */ + public function testReDoSProtectionForTooManyPlaceholders(): void + { + $mockHandler = new MockHandler([ + new Response(200, [], $this->jsonEncode(['id' => 123])) + ]); + + $handlerStack = HandlerStack::create($mockHandler); + $client = new DiscogsClient(['handler' => $handlerStack]); + + $this->expectException(InvalidArgumentException::class); + $this->expectExceptionMessage('Too many placeholders in URI'); + + // Create URI with too many placeholders to trigger protection + $manyPlaceholders = ''; + for ($i = 0; $i < 51; $i++) { + $manyPlaceholders .= '/param' . $i . '/{param' . $i . '}'; + } + + // Use reflection to inject a malicious operation + $reflection = new ReflectionClass($client); + $property = $reflection->getProperty('config'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $property->setAccessible(true); + + $config = $property->getValue($client); + $config['operations']['testManyPlaceholders'] = [ + 'httpMethod' => 'GET', + 'uri' => $manyPlaceholders + ]; + $property->setValue($client, $config); + + // Use reflection to call the operation via the magic __call method + $reflection = new ReflectionClass($client); + $method = $reflection->getMethod('__call'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + // This should trigger the placeholder protection + $method->invoke($client, 'testManyPlaceholders', [['id' => '123']]); + } + + /** + * @throws ReflectionException If reflection operations fail + */ + public function testCryptographicallySecureNonceGeneration(): void + { + $helper = new OAuthHelper(); + + // Use reflection to access the private generateNonce method + $reflection = new ReflectionClass($helper); + $method = $reflection->getMethod('generateNonce'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + // Generate multiple nonces + $nonces = []; + for ($i = 0; $i < 100; $i++) { + $nonce = $method->invoke($helper); + $nonces[] = $nonce; + + // Each nonce should be 32 characters (16 bytes * 2 for hex) + $this->assertEquals(32, strlen($nonce)); + + // Should contain only valid hex characters + $this->assertMatchesRegularExpression('/^[a-f0-9]{32}$/', $nonce); + } + + // All nonces should be unique (cryptographically secure) + $uniqueNonces = array_unique($nonces); + $this->assertSameSize($nonces, $uniqueNonces, 'All nonces should be unique'); + } + + /** + * @throws ReflectionException If reflection operations fail + */ + public function testNonceEntropyQuality(): void + { + $helper = new OAuthHelper(); + + // Use reflection to access the private generateNonce method + $reflection = new ReflectionClass($helper); + $method = $reflection->getMethod('generateNonce'); + /** @noinspection PhpExpressionResultUnusedInspection */ + $method->setAccessible(true); + + // Generate a large sample of nonces + $nonces = []; + for ($i = 0; $i < 1000; $i++) { + $nonces[] = $method->invoke($helper); + } + + // Test character distribution (should be roughly uniform for hex) + $charCounts = []; + foreach ($nonces as $nonce) { + for ($i = 0; $i < strlen($nonce); $i++) { + $char = $nonce[$i]; + $charCounts[$char] = ($charCounts[$char] ?? 0) + 1; + } + } + + // For good entropy, each hex character (0-9, a-f) should appear roughly the same number of times + // With 1000 nonces * 32 chars = 32000 total chars, each of 16 hex chars should appear ~2000 times + $validHexChars = '0123456789abcdef'; + for ($i = 0; $i < strlen($validHexChars); $i++) { + $char = $validHexChars[$i]; + $count = $charCounts[$char] ?? 0; + + // Allow some variance (ยฑ30%) for statistical variation + $this->assertGreaterThan(1400, $count, "Character '$char' appears too rarely"); + $this->assertLessThan(2600, $count, "Character '$char' appears too frequently"); + } + } + + public function testValidInputPassesThroughSafely(): void + { + $mockHandler = new MockHandler([ + new Response(200, [], $this->jsonEncode(['id' => 139250, 'name' => 'Test Artist'])) + ]); + + $handlerStack = HandlerStack::create($mockHandler); + $client = new DiscogsClient(['handler' => $handlerStack]); + + // Normal, safe input should work fine + $result = $client->getArtist(139250); + + $this->assertIsArray($result); + $this->assertEquals(139250, $result['id']); + $this->assertEquals('Test Artist', $result['name']); + } + + public function testSecurityValidationDoesNotBreakNormalFlow(): void + { + $mockHandler = new MockHandler([ + new Response(200, [], $this->jsonEncode(['results' => []])), + new Response(200, [], $this->jsonEncode(['id' => 139250, 'name' => 'Test Artist'])), + ]); + + $handlerStack = HandlerStack::create($mockHandler); + $client = new DiscogsClient(['handler' => $handlerStack]); + + // Make normal API calls that should pass security validation + $searchResult = $client->search('test'); + $artistResult = $client->getArtist(139250); + + $this->assertIsArray($searchResult); + $this->assertEquals([], $searchResult['results']); + + $this->assertIsArray($artistResult); + $this->assertEquals(139250, $artistResult['id']); + } +} diff --git a/tests/Unit/UnitTestCase.php b/tests/Unit/UnitTestCase.php new file mode 100644 index 0000000..1eeefe7 --- /dev/null +++ b/tests/Unit/UnitTestCase.php @@ -0,0 +1,79 @@ + $data + */ + protected function jsonEncode(array $data): string + { + return json_encode($data) ?: '{}'; + } + + /** + * Assert that the response contains required artist fields + * + * @param array $artist + */ + protected function assertValidArtistResponse(array $artist): void + { + $this->assertValidResponse($artist); + $this->assertArrayHasKey('name', $artist); + $this->assertIsString($artist['name']); + } + + /** + * Assert that response contains valid basic structure + * + * @param array $response + */ + protected function assertValidResponse(array $response): void + { + $this->assertIsArray($response); + $this->assertNotEmpty($response); + } + + /** + * Assert that response contains required search result structure + * + * @param array $searchResults + */ + protected function assertValidSearchResponse(array $searchResults): void + { + $this->assertValidResponse($searchResults); + $this->assertArrayHasKey('results', $searchResults); + $this->assertIsArray($searchResults['results']); + } + + /** + * Assert that the OAuth header contains an expected format + */ + protected function assertValidOAuthHeader(string $authHeader): void + { + $this->assertStringContainsString('OAuth', $authHeader); + $this->assertStringContainsString('oauth_consumer_key=', $authHeader); + $this->assertStringContainsString('oauth_token=', $authHeader); + } + + /** + * Assert that the Personal Access Token header contains an expected format + */ + protected function assertValidPersonalTokenHeader(string $authHeader): void + { + $this->assertStringContainsString('Discogs', $authHeader); + $this->assertStringContainsString('token=', $authHeader); + $this->assertStringNotContainsString('key=', $authHeader); + $this->assertStringNotContainsString('secret=', $authHeader); + } +} diff --git a/tests/fixtures/change_listing.json b/tests/fixtures/change_listing.json deleted file mode 100644 index 7cbbd10..0000000 --- a/tests/fixtures/change_listing.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "version": 1.1, - "status": 204, - "reason": "OK", - "headers": [ - { "Reproxy-Status": "yes" }, - { "Access-Control-Allow-Origin": "*" }, - { "Cache-Control": "public, must-revalidate" }, - { "Content-Type": "application/json" }, - { "Server": "lighttpd" }, - { "Content-Length": 780 }, - { "Date": "Tue, 15 Jul 2014 19:59:59 GMT" }, - { "X-Varnish": 1702965334 }, - { "Age": 0 }, - { "Via": "1.1 varnish" }, - { "Connection": "keep-alive" } - ], - "body": {} -} diff --git a/tests/fixtures/change_order.json b/tests/fixtures/change_order.json deleted file mode 100644 index 4c0da6a..0000000 --- a/tests/fixtures/change_order.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "version": 1.1, - "status": 200, - "reason": "OK", - "headers": [ - "Reproxy-Status: yes", - "Access-Control-Allow-Origin: *", - "Cache-Control: public, must-revalidate", - "Content-Type: application/json", - "Server: lighttpd", - "Content-Length: 780", - "Date: Tue, 15 Jul 2014 19:59:59 GMT", - "X-Varnish: 1702965334", - "Age: 0", - "Via: 1.1 varnish", - "Connection: keep-alive" - ], - "body": { - "id": "1-1", - "resource_url": "https://api.discogs.com/marketplace/orders/1-1", - "messages_url": "https://api.discogs.com/marketplace/orders/1-1/messages", - "uri": "http://www.discogs.com/sell/order/1-1", - "status": "Invoice Sent", - "next_status": [ - "New Order", - "Buyer Contacted", - "Invoice Sent", - "Payment Pending", - "Payment Received", - "Shipped", - "Refund Sent", - "Cancelled (Non-Paying Buyer)", - "Cancelled (Item Unavailable)", - "Cancelled (Per Buyer's Request)" - ], - "fee": { - "currency": "USD", - "value": 2.52 - }, - "created": "2011-10-21T09:25:17", - "items": [ - { - "release": { - "id": 1, - "description": "Persuader, The - Stockholm (2x12\")" - }, - "price": { - "currency": "USD", - "value": 42.0 - }, - "id": 41578242 - } - ], - "shipping": { - "currency": "USD", - "value": 5.0 - }, - "shipping_address": "Asdf Exampleton\n234 NE Asdf St.\nAsdf Town, Oregon, 14423\nUnited States\n\nPhone: 555-555-2733\nPaypal address: asdf@example.com", - "additional_instructions": "please use sturdy packaging.", - "seller": { - "resource_url": "https://api.discogs.com/users/example_seller", - "username": "example_seller", - "id": 1 - }, - "last_activity": "2011-10-22T19:18:53", - "buyer": { - "resource_url": "https://api.discogs.com/users/example_buyer", - "username": "example_buyer", - "id": 2 - }, - "total": { - "currency": "USD", - "value": 47.0 - } - } -} diff --git a/tests/fixtures/create_listing.json b/tests/fixtures/create_listing.json deleted file mode 100644 index e80fba7..0000000 --- a/tests/fixtures/create_listing.json +++ /dev/null @@ -1,22 +0,0 @@ -{ - "version": 1.1, - "status": 204, - "reason": "OK", - "headers": [ - { "Reproxy-Status": "yes" }, - { "Access-Control-Allow-Origin": "*" }, - { "Cache-Control": "public, must-revalidate" }, - { "Content-Type": "application/json" }, - { "Server": "lighttpd" }, - { "Content-Length": 780 }, - { "Date": "Tue, 15 Jul 2014 19:59:59 GMT" }, - { "X-Varnish": 1702965334 }, - { "Age": 0 }, - { "Via": "1.1 varnish" }, - { "Connection": "keep-alive" } - ], - "body": { - "listing_id": 41578241, - "resource_url": "https://api.discogs.com/marketplace/listings/41578241" - } -} diff --git a/tests/fixtures/delete_listing.json b/tests/fixtures/delete_listing.json deleted file mode 100644 index 7cbbd10..0000000 --- a/tests/fixtures/delete_listing.json +++ /dev/null @@ -1,19 +0,0 @@ -{ - "version": 1.1, - "status": 204, - "reason": "OK", - "headers": [ - { "Reproxy-Status": "yes" }, - { "Access-Control-Allow-Origin": "*" }, - { "Cache-Control": "public, must-revalidate" }, - { "Content-Type": "application/json" }, - { "Server": "lighttpd" }, - { "Content-Length": 780 }, - { "Date": "Tue, 15 Jul 2014 19:59:59 GMT" }, - { "X-Varnish": 1702965334 }, - { "Age": 0 }, - { "Via": "1.1 varnish" }, - { "Connection": "keep-alive" } - ], - "body": {} -} diff --git a/tests/fixtures/get_artist.json b/tests/fixtures/get_artist.json deleted file mode 100644 index 9eb1546..0000000 --- a/tests/fixtures/get_artist.json +++ /dev/null @@ -1,202 +0,0 @@ -{ - "version": 1.1, - "status": 200, - "reason": "OK", - "headers": [ - { "Reproxy-Status": "yes" }, - { "Access-Control-Allow-Origin": "*" }, - { "Cache-Control": "public, must-revalidate" }, - { "Content-Type": "application/json" }, - { "Server": "lighttpd" }, - { "Content-Length": 5103 }, - { "Date": "Mon, 28 Jul 2014 18:40:39 GMT" }, - { "X-Varnish": "1913846781 1913780733" }, - { "Age": 259 }, - { "Via": "1.1 varnish" }, - { "Connection": "close" } - ], - "body": { - "profile": "British electronic musician and composer (born August 18, 1971 in Limerick, Ireland).\r\nAfter having released a number of albums and EPs on Warp Records, Rephlex and other labels under many aliases, he gained more and more success from the mid-90s with releases such as \"Come to Daddy\" in 1997 (#36 on UK charts) and \"Windowlicker\" in 1999 (#16 on UK charts).\r\nIn 1991, he co-founded the label [l=Rephlex] with Grant Wilson-Claridge.\r\n", - "realname": "Richard David James", - "releases_url": "https://api.discogs.com/artists/45/releases", - "name": "Aphex Twin", - "uri": "http://www.discogs.com/artist/45-Aphex-Twin", - "urls": [ - "http://www.drukqs.net/", - "http://warp.net/records/aphex-twin", - "http://www.littlebig-agency.net/artists/aphex-twin/", - "http://www.facebook.com/aphextwinafx", - "http://twitter.com/AphexTwin", - "http://aphextwin.sandbag.uk.com/", - "http://en.wikipedia.org/wiki/Aphex_twin", - "http://www.whosampled.com/Aphex-Twin/", - "http://soundcloud.com/aphex-twin-official", - "http://www.youtube.com/user/soundofworldcontrol" - ], - "images": [ - { - "uri": "https://api.discogs.com/image/A-45-1176664580.jpeg", - "height": 704, - "width": 486, - "resource_url": "https://api.discogs.com/image/A-45-1176664580.jpeg", - "type": "primary", - "uri150": "https://api.discogs.com/image/A-150-45-1176664580.jpeg" - }, - { - "uri": "https://api.discogs.com/image/A-45-1347812770-2937.jpeg", - "height": 665, - "width": 500, - "resource_url": "https://api.discogs.com/image/A-45-1347812770-2937.jpeg", - "type": "secondary", - "uri150": "https://api.discogs.com/image/A-150-45-1347812770-2937.jpeg" - }, - { - "uri": "https://api.discogs.com/image/A-45-1209415630.jpeg", - "height": 500, - "width": 454, - "resource_url": "https://api.discogs.com/image/A-45-1209415630.jpeg", - "type": "secondary", - "uri150": "https://api.discogs.com/image/A-150-45-1209415630.jpeg" - }, - { - "uri": "https://api.discogs.com/image/A-45-1126949091.jpeg", - "height": 600, - "width": 416, - "resource_url": "https://api.discogs.com/image/A-45-1126949091.jpeg", - "type": "secondary", - "uri150": "https://api.discogs.com/image/A-150-45-1126949091.jpeg" - }, - { - "uri": "https://api.discogs.com/image/A-45-1126949071.jpeg", - "height": 280, - "width": 250, - "resource_url": "https://api.discogs.com/image/A-45-1126949071.jpeg", - "type": "secondary", - "uri150": "https://api.discogs.com/image/A-150-45-1126949071.jpeg" - }, - { - "uri": "https://api.discogs.com/image/A-45-1388347702-9375.png", - "height": 324, - "width": 500, - "resource_url": "https://api.discogs.com/image/A-45-1388347702-9375.png", - "type": "secondary", - "uri150": "https://api.discogs.com/image/A-150-45-1388347702-9375.png" - }, - { - "uri": "https://api.discogs.com/image/A-45-1389288802-7408.png", - "height": 366, - "width": 274, - "resource_url": "https://api.discogs.com/image/A-45-1389288802-7408.png", - "type": "secondary", - "uri150": "https://api.discogs.com/image/A-150-45-1389288802-7408.png" - }, - { - "uri": "https://api.discogs.com/image/A-45-1403892052-9961.jpeg", - "height": 389, - "width": 541, - "resource_url": "https://api.discogs.com/image/A-45-1403892052-9961.jpeg", - "type": "secondary", - "uri150": "https://api.discogs.com/image/A-150-45-1403892052-9961.jpeg" - }, - { - "uri": "https://api.discogs.com/image/A-45-1403892037-5712.jpeg", - "height": 458, - "width": 306, - "resource_url": "https://api.discogs.com/image/A-45-1403892037-5712.jpeg", - "type": "secondary", - "uri150": "https://api.discogs.com/image/A-150-45-1403892037-5712.jpeg" - } - ], - "resource_url": "https://api.discogs.com/artists/45", - "aliases": [ - { - "resource_url": "https://api.discogs.com/artists/42476", - "id": 42476, - "name": "Blue Calx (2)" - }, - { - "resource_url": "https://api.discogs.com/artists/32985", - "id": 32985, - "name": "Bradley Strider" - }, - { - "resource_url": "https://api.discogs.com/artists/803581", - "id": 803581, - "name": "Brian Tregaskin" - }, - { - "resource_url": "https://api.discogs.com/artists/48", - "id": 48, - "name": "Caustic Window" - }, - { - "resource_url": "https://api.discogs.com/artists/820", - "id": 820, - "name": "Dice Man, The" - }, - { "resource_url": "https://api.discogs.com/artists/46", "id": 46, "name": "GAK" }, - { - "resource_url": "https://api.discogs.com/artists/829972", - "id": 829972, - "name": "Karen Tregaskin" - }, - { - "resource_url": "https://api.discogs.com/artists/3054120", - "id": 3054120, - "name": "Patrick Tregaskin" - }, - { - "resource_url": "https://api.discogs.com/artists/1645212", - "id": 1645212, - "name": "PBoD" - }, - { - "resource_url": "https://api.discogs.com/artists/2931", - "id": 2931, - "name": "Polygon Window" - }, - { - "resource_url": "https://api.discogs.com/artists/599", - "id": 599, - "name": "Power-Pill" - }, - { - "resource_url": "https://api.discogs.com/artists/37272", - "id": 37272, - "name": "Q-Chastic" - }, - { - "resource_url": "https://api.discogs.com/artists/435132", - "id": 435132, - "name": "Richard D. James" - }, - { - "resource_url": "https://api.discogs.com/artists/286337", - "id": 286337, - "name": "Smojphace" - }, - { - "resource_url": "https://api.discogs.com/artists/3671244", - "id": 3671244, - "name": "Soit - P.P." - }, - { - "resource_url": "https://api.discogs.com/artists/798219", - "id": 798219, - "name": "Tuss, The" - } - ], - "id": 45, - "data_quality": "Correct", - "namevariations": [ - "A-F-X Twin", - "A.F.X.", - "A.Twin", - "AFX", - "Apex Twin", - "Aphex Twin, The", - "Aphex Twins", - "TheAphexTwin" - ] - } -} diff --git a/tests/fixtures/get_artist_releases.json b/tests/fixtures/get_artist_releases.json deleted file mode 100644 index c0c9c4c..0000000 --- a/tests/fixtures/get_artist_releases.json +++ /dev/null @@ -1,611 +0,0 @@ -{ - "version": 1.1, - "status": 200, - "reason": "OK", - "headers": [ - { "Reproxy-Status": "yes" }, - { "Access-Control-Allow-Origin": "*" }, - { "Cache-Control": "public, must-revalidate" }, - { "Content-Type": "application/json" }, - { - "Link": "; rel='last', ; rel='next'" - }, - { "Server": "lighttpd" }, - { "Content-Length": 14241 }, - { "Date": "Mon, 28 Jul 2014 19:37:05 GMT" }, - { "X-Varnish": 1914710838 }, - { "Age": 0 }, - { "Via": "1.1 varnish" }, - { "Connection": "close" } - ], - "body": { - "pagination": { - "per_page": 50, - "items": 724, - "page": 1, - "urls": { - "last": "https://api.discogs.com/artists/45/releases?per_page=50&page=15", - "next": "https://api.discogs.com/artists/45/releases?per_page=50&page=2" - }, - "pages": 15 - }, - "releases": [ - { - "thumb": "https://api.discogs.com/image/R-150-63114-1148806222.jpeg", - "artist": "Aphex Twin", - "main_release": 63114, - "title": "Analog Bubblebath Vol 2", - "role": "Main", - "year": 1991, - "resource_url": "https://api.discogs.com/masters/258478", - "type": "master", - "id": 258478 - }, - { - "thumb": "https://api.discogs.com/image/R-150-13860-1145127015.jpeg", - "artist": "Aphex Twin, The*", - "main_release": 13860, - "title": "Analogue Bubblebath", - "role": "Main", - "year": 1991, - "resource_url": "https://api.discogs.com/masters/870", - "type": "master", - "id": 870 - }, - { - "thumb": "https://api.discogs.com/image/R-150-4690-1128776401.jpeg", - "artist": "Aphex Twin, The*", - "main_release": 4690, - "title": "Digeridoo", - "role": "Main", - "year": 1992, - "resource_url": "https://api.discogs.com/masters/604", - "type": "master", - "id": 604 - }, - { - "thumb": "https://api.discogs.com/image/R-150-32662-1221896955.jpeg", - "artist": "Aphex Twin", - "main_release": 32662, - "title": "Selected Ambient Works 85-92", - "role": "Main", - "year": 1992, - "resource_url": "https://api.discogs.com/masters/565", - "type": "master", - "id": 565 - }, - { - "thumb": "https://api.discogs.com/image/R-150-1788489-1243368400.jpeg", - "artist": "Aphex Twin", - "main_release": 1788489, - "title": "Xylem Tube E.P.", - "role": "Main", - "year": 1992, - "resource_url": "https://api.discogs.com/masters/984", - "type": "master", - "id": 984 - }, - { - "thumb": "https://api.discogs.com/image/R-150-28763-1203866990.jpeg", - "artist": "AFX*", - "main_release": 28763, - "title": "Analogue Bubblebath Vol. 3", - "role": "Main", - "year": 1993, - "resource_url": "https://api.discogs.com/masters/919", - "type": "master", - "id": 919 - }, - { - "thumb": "https://api.discogs.com/image/R-150-2133502-1353542406-9633.jpeg", - "artist": "Aphex Twin", - "main_release": 2133502, - "title": "On", - "role": "Main", - "year": 1993, - "resource_url": "https://api.discogs.com/masters/715", - "type": "master", - "id": 715 - }, - { - "thumb": "https://api.discogs.com/image/R-150-13102-1378424659-1271.jpeg", - "artist": "AFX*", - "main_release": 13102, - "title": "Analogue Bubblebath 4", - "role": "Main", - "year": 1994, - "resource_url": "https://api.discogs.com/masters/2429", - "type": "master", - "id": 2429 - }, - { - "thumb": "https://api.discogs.com/image/R-150-3636-1271625781.jpeg", - "artist": "Aphex Twin", - "main_release": 3636, - "title": "Selected Ambient Works Volume II", - "role": "Main", - "year": 1994, - "resource_url": "https://api.discogs.com/masters/481", - "type": "master", - "id": 481 - }, - { - "status": "Accepted", - "thumb": "https://api.discogs.com/image/R-150-15608-1232354476.jpeg", - "title": "Words & Music", - "format": "CD, Promo", - "label": "Sire, Warner Bros. Records", - "role": "Main", - "year": 1994, - "resource_url": "https://api.discogs.com/releases/15608", - "artist": "Aphex Twin", - "type": "release", - "id": 15608 - }, - { - "thumb": "https://api.discogs.com/image/R-150-27129-1158407369.jpeg", - "artist": "Aphex Twin", - "main_release": 27129, - "title": "...I Care Because You Do", - "role": "Main", - "year": 1995, - "resource_url": "https://api.discogs.com/masters/461", - "type": "master", - "id": 461 - }, - { - "status": "Accepted", - "thumb": "https://api.discogs.com/image/R-150-160224-001.jpg", - "title": "Analogue Bubblebath 5", - "format": "12\", W/Lbl, Promo", - "label": "Rephlex", - "role": "Main", - "year": 1995, - "resource_url": "https://api.discogs.com/releases/160224", - "artist": "AFX*", - "type": "release", - "id": 160224 - }, - { - "thumb": "https://api.discogs.com/image/R-150-62679-1148804897.jpeg", - "artist": "Aphex Twin, The*", - "main_release": 62679, - "title": "Classics", - "role": "Main", - "year": 1995, - "resource_url": "https://api.discogs.com/masters/2482", - "type": "master", - "id": 2482 - }, - { - "status": "Accepted", - "thumb": "https://api.discogs.com/images/spacer.gif", - "title": "Donkey Rhubarb", - "format": "12\", EP, Promo", - "label": "Warp Records", - "role": "Main", - "year": 1995, - "resource_url": "https://api.discogs.com/releases/5889948", - "artist": "Aphex Twin", - "type": "release", - "id": 5889948 - }, - { - "thumb": "https://api.discogs.com/image/R-150-3663-1235218369.jpeg", - "artist": "Aphex Twin", - "main_release": 3663, - "title": "Donkey Rhubarb", - "role": "Main", - "year": 1995, - "resource_url": "https://api.discogs.com/masters/791", - "type": "master", - "id": 791 - }, - { - "status": "Accepted", - "thumb": "https://api.discogs.com/image/R-150-136-1158510598.jpeg", - "title": "Hangable Auto Bulb EP", - "format": "12\", EP, Ltd", - "label": "Warp Records", - "role": "Main", - "year": 1995, - "resource_url": "https://api.discogs.com/releases/136", - "artist": "AFX*", - "type": "release", - "id": 136 - }, - { - "thumb": "https://api.discogs.com/image/R-150-137-1163948503.jpeg", - "artist": "AFX*", - "main_release": 137, - "title": "Hangable Auto Bulb EP.2", - "role": "Main", - "year": 1995, - "resource_url": "https://api.discogs.com/masters/4984", - "type": "master", - "id": 4984 - }, - { - "status": "Accepted", - "thumb": "https://api.discogs.com/image/R-150-182628-1365780969-9823.jpeg", - "title": "In-Store Ambients", - "format": "CD, Promo, Smplr, Comp", - "label": "Warp Records, EastWest, Sire", - "role": "Main", - "year": 1995, - "resource_url": "https://api.discogs.com/releases/182628", - "artist": "Aphex Twin / Black Dog, The", - "type": "release", - "id": 182628 - }, - { - "status": "Accepted", - "thumb": "https://api.discogs.com/image/R-150-202433-1144694890.jpeg", - "title": "Raising The Titanic", - "format": "12\", Promo", - "label": "Point Music", - "role": "Main", - "year": 1995, - "resource_url": "https://api.discogs.com/releases/202433", - "artist": "Aphex Twin / Gavin Bryars", - "type": "release", - "id": 202433 - }, - { - "thumb": "https://api.discogs.com/image/R-150-27983-1228338398.jpeg", - "artist": "Aphex Twin", - "main_release": 27983, - "title": "Ventolin E.P", - "role": "Main", - "year": 1995, - "resource_url": "https://api.discogs.com/masters/20524", - "type": "master", - "id": 20524 - }, - { - "thumb": "https://api.discogs.com/image/R-150-757-1169134962.jpeg", - "artist": "Aphex Twin", - "main_release": 757, - "title": "\"Girl/Boy\" E.P.", - "role": "Main", - "year": 1996, - "resource_url": "https://api.discogs.com/masters/2503", - "type": "master", - "id": 2503 - }, - { - "thumb": "https://api.discogs.com/image/R-150-62499-1233439583.jpeg", - "artist": "Aphex Twin", - "main_release": 62499, - "title": "51/13 Aphex Singles Collection", - "role": "Main", - "year": 1996, - "resource_url": "https://api.discogs.com/masters/2459", - "type": "master", - "id": 2459 - }, - { - "status": "Accepted", - "thumb": "https://api.discogs.com/image/R-150-3157006-1318364201.jpeg", - "title": "Come To Daddy (8MM Huit Millim\u00e8tres Press Kit)", - "format": "CD, Promo", - "label": "Virgin France S.A.", - "role": "Main", - "year": 1996, - "resource_url": "https://api.discogs.com/releases/3157006", - "artist": "Aphex Twin", - "type": "release", - "id": 3157006 - }, - { - "thumb": "https://api.discogs.com/image/R-150-30849-1336914949-4168.jpeg", - "artist": "Aphex Twin", - "main_release": 30849, - "title": "Richard D. James Album", - "role": "Main", - "year": 1996, - "resource_url": "https://api.discogs.com/masters/510", - "type": "master", - "id": 510 - }, - { - "thumb": "https://api.discogs.com/image/R-150-29131-1100781969.jpg", - "artist": "Mike Flowers Pops, The* Meets Aphex Twin, The*", - "main_release": 29131, - "title": "The Freebase Connection", - "role": "Main", - "year": 1996, - "resource_url": "https://api.discogs.com/masters/118531", - "type": "master", - "id": 118531 - }, - { - "thumb": "https://api.discogs.com/image/R-150-97039-1162418223.jpeg", - "artist": "AFX*", - "main_release": 97039, - "title": "Analogue Bubblebath Vol. 3.1", - "role": "Main", - "year": 1997, - "resource_url": "https://api.discogs.com/masters/617160", - "type": "master", - "id": 617160 - }, - { - "thumb": "https://api.discogs.com/image/R-150-52184-1301342800.jpeg", - "artist": "Aphex Twin", - "main_release": 52184, - "title": "Come To Daddy", - "role": "Main", - "year": 1997, - "resource_url": "https://api.discogs.com/masters/27457", - "type": "master", - "id": 27457 - }, - { - "status": "Accepted", - "thumb": "https://api.discogs.com/image/R-150-2756969-1299652845.jpeg", - "title": "Come To Daddy (Director's Cut)", - "format": "VHS, NTSC, Promo", - "label": "Sire Records Company", - "role": "Main", - "year": 1997, - "resource_url": "https://api.discogs.com/releases/2756969", - "artist": "Aphex Twin", - "type": "release", - "id": 2756969 - }, - { - "thumb": "https://api.discogs.com/image/R-150-76381-1224235531.jpeg", - "artist": "Aphex Twin", - "main_release": 76381, - "title": "Come To Viddy", - "role": "Main", - "year": 1997, - "resource_url": "https://api.discogs.com/masters/12991", - "type": "master", - "id": 12991 - }, - { - "status": "Accepted", - "thumb": "https://api.discogs.com/image/R-150-798311-1179765223.jpeg", - "title": "Inkey$", - "format": "CD, Single, Promo", - "label": "Warp Records, Source", - "role": "Main", - "year": 1998, - "resource_url": "https://api.discogs.com/releases/798311", - "artist": "Aphex Twin", - "type": "release", - "id": 798311 - }, - { - "status": "Accepted", - "thumb": "https://api.discogs.com/image/R-150-839805-1164133158.jpeg", - "title": "Sugar Daddy / Come To Daddy", - "format": "VHS, SECAM, Promo", - "label": "Source, Warp Records", - "role": "Main", - "year": 1998, - "resource_url": "https://api.discogs.com/releases/839805", - "artist": "Jimi Tenor / Aphex Twin", - "type": "release", - "id": 839805 - }, - { - "status": "Accepted", - "thumb": "https://api.discogs.com/image/R-150-611166-1161158760.jpeg", - "title": "Special Sampler", - "format": "CD, Maxi, Promo, Smplr", - "label": "WEA", - "role": "Main", - "year": 1999, - "resource_url": "https://api.discogs.com/releases/611166", - "artist": "Aphex Twin", - "type": "release", - "id": 611166 - }, - { - "thumb": "https://api.discogs.com/image/R-150-19290-1272803726.jpeg", - "artist": "Aphex Twin", - "main_release": 19290, - "title": "Windowlicker", - "role": "Main", - "year": 1999, - "resource_url": "https://api.discogs.com/masters/532", - "type": "master", - "id": 532 - }, - { - "thumb": "https://api.discogs.com/image/R-150-8433-1197641847.jpeg", - "artist": "AFX*", - "main_release": 8433, - "title": "2 Remixes By AFX", - "role": "Main", - "year": 2001, - "resource_url": "https://api.discogs.com/masters/2408", - "type": "master", - "id": 2408 - }, - { - "thumb": "https://api.discogs.com/image/R-150-123948-1107088837.jpg", - "artist": "Aphex Twin", - "main_release": 123948, - "title": "Drukqs", - "role": "Main", - "year": 2001, - "resource_url": "https://api.discogs.com/masters/281813", - "type": "master", - "id": 281813 - }, - { - "thumb": "https://api.discogs.com/image/R-150-13965-1249315114.jpeg", - "artist": "Aphex Twin", - "main_release": 13965, - "title": "Drukqs", - "role": "Main", - "year": 2001, - "resource_url": "https://api.discogs.com/masters/497", - "type": "master", - "id": 497 - }, - { - "thumb": "https://api.discogs.com/image/R-150-16980-1228269629.jpeg", - "artist": "Aphex Twin", - "main_release": 16980, - "title": "Drukqs 2 Track Promo", - "role": "Main", - "year": 2001, - "resource_url": "https://api.discogs.com/masters/27144", - "type": "master", - "id": 27144 - }, - { - "status": "Accepted", - "thumb": "https://api.discogs.com/image/R-150-1692451-1354536855-8321.jpeg", - "title": "Sound Aphex", - "format": "CD, Promo, Comp, Car", - "label": "Chrysalis", - "role": "Main", - "year": 2001, - "resource_url": "https://api.discogs.com/releases/1692451", - "artist": "Aphex Twin", - "type": "release", - "id": 1692451 - }, - { - "status": "Accepted", - "thumb": "https://api.discogs.com/image/R-150-194780-001.jpg", - "title": "2 Mixes On A 12\" For Cash", - "format": "12\"", - "label": "Beat Records", - "role": "Main", - "year": 2003, - "resource_url": "https://api.discogs.com/releases/194780", - "artist": "Aphex Twin", - "type": "release", - "id": 194780 - }, - { - "thumb": "https://api.discogs.com/image/R-150-127710-1245269123.jpeg", - "artist": "Aphex Twin", - "main_release": 127710, - "title": "26 Mixes For Cash", - "role": "Main", - "year": 2003, - "resource_url": "https://api.discogs.com/masters/836", - "type": "master", - "id": 836 - }, - { - "thumb": "https://api.discogs.com/image/R-150-154004-003.jpg", - "artist": "AFX*", - "main_release": 154004, - "title": "Smojphace EP", - "role": "Main", - "year": 2003, - "resource_url": "https://api.discogs.com/masters/9735", - "type": "master", - "id": 9735 - }, - { - "thumb": "https://api.discogs.com/image/R-150-385638-1165171889.jpeg", - "artist": "AFX*", - "main_release": 385638, - "title": "Analord 01", - "role": "Main", - "year": 2005, - "resource_url": "https://api.discogs.com/masters/210429", - "type": "master", - "id": 210429 - }, - { - "thumb": "https://api.discogs.com/image/R-150-385640-1165172024.jpeg", - "artist": "AFX*", - "main_release": 385640, - "title": "Analord 02", - "role": "Main", - "year": 2005, - "resource_url": "https://api.discogs.com/masters/210499", - "type": "master", - "id": 210499 - }, - { - "thumb": "https://api.discogs.com/image/R-150-400077-1165172084.jpeg", - "artist": "AFX*", - "main_release": 400077, - "title": "Analord 03", - "role": "Main", - "year": 2005, - "resource_url": "https://api.discogs.com/masters/210501", - "type": "master", - "id": 210501 - }, - { - "thumb": "https://api.discogs.com/image/R-150-398345-1165172133.jpeg", - "artist": "AFX*", - "main_release": 398345, - "title": "Analord 04", - "role": "Main", - "year": 2005, - "resource_url": "https://api.discogs.com/masters/210510", - "type": "master", - "id": 210510 - }, - { - "thumb": "https://api.discogs.com/image/R-150-415851-1165172195.jpeg", - "artist": "AFX*", - "main_release": 415851, - "title": "Analord 05", - "role": "Main", - "year": 2005, - "resource_url": "https://api.discogs.com/masters/210511", - "type": "master", - "id": 210511 - }, - { - "thumb": "https://api.discogs.com/image/R-150-428328-1165172256.jpeg", - "artist": "AFX*", - "main_release": 428328, - "title": "Analord 06", - "role": "Main", - "year": 2005, - "resource_url": "https://api.discogs.com/masters/210512", - "type": "master", - "id": 210512 - }, - { - "thumb": "https://api.discogs.com/image/R-150-444484-1165172313.jpeg", - "artist": "AFX*", - "main_release": 444484, - "title": "Analord 07", - "role": "Main", - "year": 2005, - "resource_url": "https://api.discogs.com/masters/210514", - "type": "master", - "id": 210514 - }, - { - "thumb": "https://api.discogs.com/image/R-150-451034-1165172382.jpeg", - "artist": "AFX*", - "main_release": 451034, - "title": "Analord 08", - "role": "Main", - "year": 2005, - "resource_url": "https://api.discogs.com/masters/210515", - "type": "master", - "id": 210515 - }, - { - "thumb": "https://api.discogs.com/image/R-150-466787-1165172488.jpeg", - "artist": "AFX*", - "main_release": 466787, - "title": "Analord 09", - "role": "Main", - "year": 2005, - "resource_url": "https://api.discogs.com/masters/210517", - "type": "master", - "id": 210517 - } - ] - } -} diff --git a/tests/fixtures/get_collection_folder.json b/tests/fixtures/get_collection_folder.json deleted file mode 100644 index 9e712bd..0000000 --- a/tests/fixtures/get_collection_folder.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "version": 1.1, - "status": 200, - "reason": "OK", - "headers": [ - { "Reproxy-Status": "yes" }, - { "Access-Control-Allow-Origin": "*" }, - { "Cache-Control": "public, must-revalidate" }, - { "Content-Type": "application/json" }, - { "Server": "lighttpd" }, - { "Content-Length": 132 }, - { "Date": "Wed, 16 Jul 2014 23:20:21 GMT" }, - { "X-Varnish": 1722533701 }, - { "Age": 0 }, - { "Via": "1.1 varnish" }, - { "Connection": "keep-alive" } - ], - "body": { - "id": 1, - "count": 20, - "name": "Uncategorized", - "resource_url": "https://api.discogs.com/users/example/collection/folders/1" - } -} diff --git a/tests/fixtures/get_collection_folders.json b/tests/fixtures/get_collection_folders.json deleted file mode 100644 index f13402c..0000000 --- a/tests/fixtures/get_collection_folders.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "version": 1.1, - "status": 200, - "reason": "OK", - "headers": [ - { "Reproxy-Status": "yes" }, - { "Access-Control-Allow-Origin": "*" }, - { "Cache-Control": "public, must-revalidate" }, - { "Content-Type": "application/json" }, - { "Server": "lighttpd" }, - { "Content-Length": 132 }, - { "Date": "Wed, 16 Jul 2014 23:20:21 GMT" }, - { "X-Varnish": 1722533701 }, - { "Age": 0 }, - { "Via": "1.1 varnish" }, - { "Connection": "keep-alive" } - ], - "body": { - "folders": [ - { - "id": 0, - "count": 23, - "name": "All", - "resource_url": "https://api.discogs.com/users/example/collection/folders/0" - }, - { - "id": 1, - "count": 20, - "name": "Uncategorized", - "resource_url": "https://api.discogs.com/users/example/collection/folders/1" - } - ] - } -} diff --git a/tests/fixtures/get_collection_items_by_folder.json b/tests/fixtures/get_collection_items_by_folder.json deleted file mode 100644 index 50ae8e3..0000000 --- a/tests/fixtures/get_collection_items_by_folder.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "version": 1.1, - "status": 200, - "reason": "OK", - "headers": [ - { "Reproxy-Status": "yes" }, - { "Access-Control-Allow-Origin": "*" }, - { "Cache-Control": "public, must-revalidate" }, - { "Content-Type": "application/json" }, - { "Server": "lighttpd" }, - { "Content-Length": 132 }, - { "Date": "Wed, 16 Jul 2014 23:20:21 GMT" }, - { "X-Varnish": "1722533701" }, - { "Age": 0 }, - { "Via": "1.1 varnish" }, - { "Connection": "keep-alive" } - ], - "body": { - "pagination": { - "per_page": 1, - "pages": 14, - "page": 1, - "items": 14, - "urls": { - "next": "https://api.discogs.com/users/example/collection/folders/1/releases?page=2&per_page=1", - "last": "https://api.discogs.com/users/example/collection/folders/1/releases?page=2&per_page=14" - } - }, - "releases": [ - { - "id": 2464521, - "instance_id": 1, - "folder_id": 1, - "rating": 0, - "basic_information": { - "id": 2464521, - "title": "Information Chase", - "year": 2006, - "resource_url": "https://api.discogs.com/releases/2464521", - "thumb": "https://api-img.discogs.com/vzpYq4_kc52GZFs14c0SCJ0ZE84=/fit-in/150x150/filters:strip_icc():format(jpeg):mode_rgb()/discogs-images/R-2464521-1285519861.jpeg.jpg", - "cover_image": "https://api-img.discogs.com/vzpYq4_kc52GZFs14c0SCJ0ZE84/fit-in/500x500/filters:strip_icc():format(jpeg):mode_rgb():quality(90)/discogs-images/R-2464521-1285519861.jpeg.jpg", - "formats": [ { "qty": "1", "descriptions": [ "Mini", "EP" ], "name": "CDr" } ], - "labels": [ - { - "resource_url": "https://api.discogs.com/labels/11647", - "entity_type": "", - "catno": "8BP059", - "id": 11647, - "name": "8bitpeoples" - } - ], - "artists": [ - { - "id": 103906, - "name": "Bit Shifter", - "join": "", - "resource_url": "https://api.discogs.com/artists/103906", - "anv": "", - "tracks": "", - "role": "" - } - ] - }, - "notes": [ { "field_id": 3, "value": "bleep bloop blorp." } ] - } - ] - } -} diff --git a/tests/fixtures/get_inventory.json b/tests/fixtures/get_inventory.json deleted file mode 100644 index be5c1c2..0000000 --- a/tests/fixtures/get_inventory.json +++ /dev/null @@ -1,1335 +0,0 @@ -{ - "version": 1.1, - "status": 200, - "reason": "OK", - "headers": [ - { "Reproxy-Status": "yes" }, - { "Access-Control-Allow-Origin": "*" }, - { "Cache-Control": "public, must-revalidate" }, - { "Content-Type": "application/json" }, - { - "Link": "; rel='last', ; rel='next'" - }, - { "Server": "lighttpd" }, - { "Content-Length": 37813 }, - { "Date": "Mon, 04 Aug 2014 19:03:15 GMT" }, - { "X-Varnish": 2033294582 }, - { "Age": 0 }, - { "Via": "1.1 varnish" }, - { "Connection": "keep-alive" } - ], - "body": { - "pagination": { - "per_page": 50, - "items": 479, - "page": 1, - "urls": { - "last": "https://api.discogs.com/users/360vinyl/inventory?sort=price&per_page=50&sort_order=asc&page=10", - "next": "https://api.discogs.com/users/360vinyl/inventory?sort=price&per_page=50&sort_order=asc&page=2" - }, - "pages": 10 - }, - "listings": [ - { - "status": "For Sale", - "price": { "currency": "USD", "value": 2.99 }, - "allow_offers": false, - "sleeve_condition": "Generic", - "id": 129242581, - "condition": "Very Good Plus (VG+)", - "posted": "2013-11-20T14:26:16", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/129242581", - "comments": "Plain white sleeve. Nice, clean vinyl.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "SBEST34", - "resource_url": "https://api.discogs.com/releases/732194", - "year": 2006, - "id": 732194, - "description": "Max Sedgley - Slowly (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/129242581", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 147577874, - "condition": "Very Good Plus (VG+)", - "posted": "2014-03-08T17:09:34", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/147577874", - "comments": "", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "FB 2533", - "resource_url": "https://api.discogs.com/releases/1249107", - "year": 2008, - "id": 1249107, - "description": "Akrobatik - Put Ya Stamp On It (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/147577874", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Not Graded", - "id": 16855160, - "condition": "Mint (M)", - "posted": "2011-09-28T11:26:46", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/16855160", - "comments": "BRAND NEW, STILL FACTORY SEALED.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "QP 069", - "resource_url": "https://api.discogs.com/releases/652625", - "year": 2005, - "id": 652625, - "description": "Apsci - See That / Bike Messenger Diaries (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/16855160", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 152754569, - "condition": "Very Good Plus (VG+)", - "posted": "2014-04-05T18:37:26", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/152754569", - "comments": "Very nice, clean vinyl. Minor shelf wear to sleeve.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "TFBA-001", - "resource_url": "https://api.discogs.com/releases/1117685", - "year": 2004, - "id": 1117685, - "description": "Beat Assailant - Hard Twelve - The Ante (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/152754569", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 59217929, - "condition": "Very Good Plus (VG+)", - "posted": "2012-04-11T18:14:57", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/59217929", - "comments": "Sticker residue at top right corner of sleeve.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "ACC006", - "resource_url": "https://api.discogs.com/releases/726988", - "year": 2004, - "id": 726988, - "description": "Bicasso - Respect Yourself / For Rent / Let's Begin (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/59217929", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Not Graded", - "id": 60302175, - "condition": "Mint (M)", - "posted": "2012-04-25T13:16:30", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/60302175", - "comments": "STILL FACTORY SEALED.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "INF 001 TLG-CD-001", - "resource_url": "https://api.discogs.com/releases/1308055", - "year": 2007, - "id": 1308055, - "description": "Bouncer Crew - Xtasy For Ladies (CD, Album)" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/60302175", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 129243050, - "condition": "Very Good Plus (VG+)", - "posted": "2013-11-20T14:42:32", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/129243050", - "comments": "Plain black sleeve. Very nice, clean vinyl. BPMs written in small numerals on center labels.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "CPV 0400", - "resource_url": "https://api.discogs.com/releases/997074", - "year": 2006, - "id": 997074, - "description": "Chimp Beams - R2 (Libyus Mix) (12\", EP)" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/129243050", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 38196213, - "condition": "Very Good Plus (VG+)", - "posted": "2011-06-11T14:22:57", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/38196213", - "comments": "Nice, clean vinyl.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "INT 8 85622 6, 7243 8 85622 6 3", - "resource_url": "https://api.discogs.com/releases/1239638", - "year": 1998, - "id": 1239638, - "description": "Cornershop - Sleep On The Left Side (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/38196213", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Mint (M)", - "id": 22048701, - "condition": "Mint (M)", - "posted": "2010-04-14T13:45:36", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/22048701", - "comments": "STILL FACTORY SEALED.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "UR12 031", - "resource_url": "https://api.discogs.com/releases/98220", - "year": 0, - "id": 98220, - "description": "DJs Wally & Swingsett / Bobby Matos - Righteous Like A Brother (DJ's Wally & Swingsett Remix) / Guiro Electro (Rainer Tr\u00fcby Trio Remix) (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/22048701", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 127085013, - "condition": "Very Good Plus (VG+)", - "posted": "2013-11-07T12:29:49", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/127085013", - "comments": "Placeholder sticker on one center label.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "STH 2051", - "resource_url": "https://api.discogs.com/releases/102572", - "year": 2002, - "id": 102572, - "description": "Dooley O - Watch My Moves 1990 (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/127085013", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 129025901, - "condition": "Very Good Plus (VG+)", - "posted": "2013-11-19T11:43:54", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/129025901", - "comments": "In generic Citrona sleeve. BPMs written in small numerals on center labels.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "CIT-006", - "resource_url": "https://api.discogs.com/releases/417926", - "year": 2005, - "id": 417926, - "description": "First Floor Brothers, The - Chi-Town Strut (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/129025901", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Not Graded", - "id": 20810838, - "condition": "Mint (M)", - "posted": "2011-09-28T11:35:01", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/20810838", - "comments": "STILL SEALED.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "LKR004", - "resource_url": "https://api.discogs.com/releases/394740", - "year": 2003, - "id": 394740, - "description": "Foreign Legion (2) - Roommate Joint (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/20810838", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 127234518, - "condition": "Very Good Plus (VG+)", - "posted": "2013-11-08T16:41:15", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/127234518", - "comments": "One light surface mark across side B. This mark does not effect playback.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "JJR 1006", - "resource_url": "https://api.discogs.com/releases/985430", - "year": 2006, - "id": 985430, - "description": "Frank-N-Dank - What Up / The Hustle (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/127234518", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Not Graded", - "id": 34739072, - "condition": "Very Good Plus (VG+)", - "posted": "2014-02-14T12:53:14", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/34739072", - "comments": "Very nice, clean copy. Just a touch away from NM. Plain white paper sleeve.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "RAMP020", - "resource_url": "https://api.discogs.com/releases/2007688", - "year": 2009, - "id": 2007688, - "description": "Hot City - Hot City Bass (10\", W/Lbl)" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/34739072", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Near Mint (NM or M-)", - "id": 57194290, - "condition": "Near Mint (NM or M-)", - "posted": "2012-03-21T15:50:31", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/57194290", - "comments": "", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "SCH 004", - "resource_url": "https://api.discogs.com/releases/1624293", - "year": 1997, - "id": 1624293, - "description": "Jeswa - Skone (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/57194290", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Generic", - "id": 131311242, - "condition": "Very Good (VG)", - "posted": "2013-12-03T14:02:50", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/131311242", - "comments": "Plain black sleeve. Surface wear, mainly cosmetic. VG in appearance, play quality is VG+", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "GAMM004", - "resource_url": "https://api.discogs.com/releases/296052", - "year": 2004, - "id": 296052, - "description": "Johnny Darkos - Supreme Love (12\", Promo)" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/131311242", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Generic", - "id": 37431871, - "condition": "Very Good Plus (VG+)", - "posted": "2011-05-25T14:27:01", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/37431871", - "comments": "BPM written on side A center label.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "WMR001", - "resource_url": "https://api.discogs.com/releases/1027286", - "year": 2007, - "id": 1027286, - "description": "Keno 1* & Hermit, The - Poppa Heavy (12\", Promo)" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/37431871", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Not Graded", - "id": 133996557, - "condition": "Mint (M)", - "posted": "2013-12-19T19:19:18", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/133996557", - "comments": "BRAND NEW, STILL FACTORY SEALED.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "DBM001", - "resource_url": "https://api.discogs.com/releases/2303699", - "year": 2010, - "id": 2303699, - "description": "Kev Brown - Random Joints (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/133996557", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Generic", - "id": 38365680, - "condition": "Very Good Plus (VG+)", - "posted": "2011-06-15T19:00:55", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/38365680", - "comments": "", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "CAS 8924-S1, XSS 8924", - "resource_url": "https://api.discogs.com/releases/1398608", - "year": 1997, - "id": 1398608, - "description": "Kulcha Don Featuring Fugees - Bellevue \"Da Bomb\" (12\", Promo)" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/38365680", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 113607427, - "condition": "Very Good Plus (VG+)", - "posted": "2013-08-15T17:47:16", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/113607427", - "comments": "In original shrink wrap.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "OH 227 SV", - "resource_url": "https://api.discogs.com/releases/1003585", - "year": 2006, - "id": 1003585, - "description": "Ladybug Mecca - Dogg Starr (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/113607427", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 65294948, - "condition": "Very Good Plus (VG+)", - "posted": "2012-06-20T12:51:25", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/65294948", - "comments": "Placeholder sticker on each center label.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "QP 0079-0", - "resource_url": "https://api.discogs.com/releases/1138004", - "year": 2007, - "id": 1138004, - "description": "Lifesavas - Gutterfly / A Serpent's Love (12\", Maxi)" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/65294948", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 149030188, - "condition": "Very Good Plus (VG+)", - "posted": "2014-03-17T19:42:05", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/149030188", - "comments": "Light wear, plays fine. 3\u201d x 1.5\u201d sticker from distribution company on front of sleeve. Small bend to top right sleeve corner.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "FC12015", - "resource_url": "https://api.discogs.com/releases/256835", - "year": 2004, - "id": 256835, - "description": "Little Brother (3), J. Sands, Ambersunshower - Hip Hop Love Soul Sampler 2 (12\", Promo)" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/149030188", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Generic", - "id": 69281186, - "condition": "Very Good Plus (VG+)", - "posted": "2012-07-25T17:09:35", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/69281186", - "comments": "Plain black sleeve. Nice, clean copy! Removable price tag on center label.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "VPRD-5221, none", - "resource_url": "https://api.discogs.com/releases/3410917", - "year": 1993, - "id": 3410917, - "description": "Louchie Lou & Michie One - Rich Girl (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/69281186", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good (VG)", - "id": 16979011, - "condition": "Near Mint (NM or M-)", - "posted": "2014-02-14T14:11:03", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/16979011", - "comments": "Bottom left and top right corners of sleeve has bends.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "PJMS0062", - "resource_url": "https://api.discogs.com/releases/99605", - "year": 2002, - "id": 99605, - "description": "Mousse T. Feat. Emma Lanford - Fire (2x12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/16979011", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 14794971, - "condition": "Very Good Plus (VG+)", - "posted": "2009-10-10T14:32:11", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/14794971", - "comments": "", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "bd012", - "resource_url": "https://api.discogs.com/releases/205641", - "year": 1999, - "id": 205641, - "description": "New Flesh For Old - Eye Of The Hurricane (12\", Single)" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/14794971", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 127556691, - "condition": "Very Good Plus (VG+)", - "posted": "2013-11-11T16:57:25", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/127556691", - "comments": "BPM's written in small numerals on center labels.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "S0R0010", - "resource_url": "https://api.discogs.com/releases/858981", - "year": 2006, - "id": 858981, - "description": "Nick Andre + E Da Boss / DJ Enki - The Singles (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/127556691", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 43776266, - "condition": "Very Good Plus (VG+)", - "posted": "2011-09-20T13:15:14", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/43776266", - "comments": "Side A has one very light surface mark that does not effect playback. Side B = NM. 3 spots of dry sticker residue near top right corner of sleeve.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "CWR12002", - "resource_url": "https://api.discogs.com/releases/1706531", - "year": 2004, - "id": 1706531, - "description": "Nine:Fifteen - Deluxe Laminated (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/43776266", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 24978380, - "condition": "Very Good Plus (VG+)", - "posted": "2010-06-29T11:24:43", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/24978380", - "comments": "", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "GFS-100-1", - "resource_url": "https://api.discogs.com/releases/242274", - "year": 2003, - "id": 242274, - "description": "Paris (2) - FNB / Evil (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/24978380", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Not Graded", - "id": 73855107, - "condition": "Very Good Plus (VG+)", - "posted": "2012-09-11T12:53:11", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/73855107", - "comments": "Very nice, clean copy! BPM sticker on sleeve.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "36295-0", - "resource_url": "https://api.discogs.com/releases/1623302", - "year": 2007, - "id": 1623302, - "description": "Politik, The - Moonlight (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/73855107", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 122751128, - "condition": "Very Good Plus (VG+)", - "posted": "2013-10-09T15:14:26", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/122751128", - "comments": "A few light hairline marks that do not effect playback. In original stickered sleeve.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "G4120011", - "resource_url": "https://api.discogs.com/releases/477800", - "year": 2001, - "id": 477800, - "description": "Qwel - Face Value (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/122751128", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 151533587, - "condition": "Very Good Plus (VG+)", - "posted": "2014-03-30T17:49:08", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/151533587", - "comments": "Nice, clean vinyl and sleeve. Price tag on sleeve.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "OM 048EP", - "resource_url": "https://api.discogs.com/releases/1140912", - "year": 2000, - "id": 1140912, - "description": "Radar (2) - Antimatter (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/151533587", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 118549111, - "condition": "Very Good Plus (VG+)", - "posted": "2013-09-16T17:23:49", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/118549111", - "comments": "", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "TR396-045", - "resource_url": "https://api.discogs.com/releases/1432961", - "year": 2008, - "id": 1432961, - "description": "Shawn Jackson (2) - Feelin' Jack (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/118549111", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Generic", - "id": 118548785, - "condition": "Very Good Plus (VG+)", - "posted": "2013-09-16T17:05:13", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/118548785", - "comments": "Comes in plain clear plastic sleeve. Bottom center of sleeve has split.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "TR396-053", - "resource_url": "https://api.discogs.com/releases/1634440", - "year": 2009, - "id": 1634440, - "description": "Shawn Jackson (2) - Maan Up! (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/118548785", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Generic", - "id": 148368100, - "condition": "Very Good Plus (VG+)", - "posted": "2014-03-12T19:39:09", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/148368100", - "comments": "Plain black sleeve. Very nice, clean vinyl, close to NM. Price tag on one center label.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "ORO9992", - "resource_url": "https://api.discogs.com/releases/766962", - "year": 2000, - "id": 766962, - "description": "Sunspot Jonz - Unleashed (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/148368100", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Generic", - "id": 124841742, - "condition": "Very Good Plus (VG+)", - "posted": "2013-10-23T15:15:52", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/124841742", - "comments": "One series of surface marks that look to be the result of the manufacturing process. These marks do not effect playback. Removable price tag on one center label. Plain black sleeve.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "VPRD 6365", - "resource_url": "https://api.discogs.com/releases/1664807", - "year": 2000, - "id": 1664807, - "description": "Devonte & Tanto Metro* / Beenie Man - Say Wodee / Bookshelf (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/124841742", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 33275280, - "condition": "Mint (M)", - "posted": "2011-02-16T14:20:59", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/33275280", - "comments": "Unplayed.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "ag104", - "resource_url": "https://api.discogs.com/releases/313857", - "year": 2000, - "id": 313857, - "description": "Textile Ranch - The Dream Of The Murderer's Ship Pulling Out (7\", Whi)" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/33275280", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Generic", - "id": 127234714, - "condition": "Very Good Plus (VG+)", - "posted": "2013-11-08T16:55:30", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/127234714", - "comments": "Plain white sleeve.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "AV 432, SSR 1017", - "resource_url": "https://api.discogs.com/releases/673167", - "year": 2004, - "id": 673167, - "description": "Theodore Unit - Who We Are (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/127234714", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 63095779, - "condition": "Very Good Plus (VG+)", - "posted": "2012-05-25T14:05:14", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/63095779", - "comments": "Price tag on sleeve.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "SUN 444", - "resource_url": "https://api.discogs.com/releases/518122", - "year": 1986, - "id": 518122, - "description": "Vicious Rumor Club, The* - Rumor Rap (Yeah, Yeah That's It) (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/63095779", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 3.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 131301578, - "condition": "Very Good Plus (VG+)", - "posted": "2013-12-03T13:32:19", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/131301578", - "comments": "", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "NSD-16", - "resource_url": "https://api.discogs.com/releases/589260", - "year": 2005, - "id": 589260, - "description": "Vordul Mega - Believe / Stay Up / Hard Times Pt. 2 (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/131301578", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 4.98 }, - "allow_offers": false, - "sleeve_condition": "Not Graded", - "id": 111502317, - "condition": "Mint (M)", - "posted": "2013-07-30T14:35:58", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/111502317", - "comments": "BRAND NEW, UNPLAYED.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "STH7030", - "resource_url": "https://api.discogs.com/releases/1841411", - "year": 2009, - "id": 1841411, - "description": "Koushik - Nothing's The Same (7\", Single)" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/111502317", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 4.99 }, - "allow_offers": false, - "sleeve_condition": "Mint (M)", - "id": 20988399, - "condition": "Mint (M)", - "posted": "2010-08-05T13:50:50", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/20988399", - "comments": "STILL FACTORY SEALED.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "GCR 7056-1", - "resource_url": "https://api.discogs.com/releases/509363", - "year": 2001, - "id": 509363, - "description": "Aceyalone - Microphones / Keep Rappin' & Spinnin' (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/20988399", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 4.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 44564812, - "condition": "Very Good Plus (VG+)", - "posted": "2014-02-14T14:59:56", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/44564812", - "comments": "Original printed inner sleeve included.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "MSB-6593", - "resource_url": "https://api.discogs.com/releases/2766992", - "year": 1978, - "id": 2766992, - "description": "B.J. Thomas - Happy Man (LP)" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/44564812", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 4.99 }, - "allow_offers": false, - "sleeve_condition": "Generic", - "id": 27124744, - "condition": "Very Good Plus (VG+)", - "posted": "2014-02-13T22:04:44", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/27124744", - "comments": "Writing on one center label.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "RT 9401", - "resource_url": "https://api.discogs.com/releases/1610543", - "year": 1990, - "id": 1610543, - "description": "Bad Boy Posse - Break North / Gimme The Mic (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/27124744", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 4.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 120908773, - "condition": "Very Good Plus (VG+)", - "posted": "2013-09-28T12:53:20", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/120908773", - "comments": "", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "ASW1003", - "resource_url": "https://api.discogs.com/releases/1140129", - "year": 2007, - "id": 1140129, - "description": "Buff1 - Pretty Baby / SUPREME (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/120908773", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 4.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 75818257, - "condition": "Very Good Plus (VG+)", - "posted": "2014-02-14T12:04:59", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/75818257", - "comments": "Nice, clean vinyl.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "RAW015", - "resource_url": "https://api.discogs.com/releases/1755293", - "year": 2009, - "id": 1755293, - "description": "Ciara (2) Feat Justin Timberlake - Love Sex Magic (House Remixes) (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/75818257", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 4.99 }, - "allow_offers": false, - "sleeve_condition": "Generic", - "id": 127559780, - "condition": "Very Good Plus (VG+)", - "posted": "2013-11-11T19:49:24", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/127559780", - "comments": "Plain white sleeve. Very nice, clean vinyl. BPM's written in small numerals on center labels.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "AFA-FW10", - "resource_url": "https://api.discogs.com/releases/924627", - "year": 2006, - "id": 924627, - "description": "Dr. Delay* - Rule Of Thumb (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/127559780", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 4.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 11557024, - "condition": "Very Good Plus (VG+)", - "posted": "2014-02-14T12:33:19", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/11557024", - "comments": "Nice, clean vinyl. Tiny notch cut on sleeve. Original shrink wrap with sticker still intact.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "0-66305", - "resource_url": "https://api.discogs.com/releases/64382", - "year": 1993, - "id": 64382, - "description": "Ethyl Meatplow - Queenie (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/11557024", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 4.99 }, - "allow_offers": false, - "sleeve_condition": "Not Graded", - "id": 20810805, - "condition": "Mint (M)", - "posted": "2010-03-13T14:36:31", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/20810805", - "comments": "BRAND NEW, STILL FACTORY SEALED.", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "CDE 0010-1", - "resource_url": "https://api.discogs.com/releases/150907", - "year": 2003, - "id": 150907, - "description": "Fakts One - The Show Starter / Life Music / We Gonna... (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/20810805", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 4.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 151438387, - "condition": "Near Mint (NM or M-)", - "posted": "2014-03-29T16:52:55", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/151438387", - "comments": "", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "LKR002", - "resource_url": "https://api.discogs.com/releases/693318", - "year": 2002, - "id": 693318, - "description": "Foreign Legion (2) - Voodoo Star (12\")" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/151438387", - "audio": false - }, - { - "status": "For Sale", - "price": { "currency": "USD", "value": 4.99 }, - "allow_offers": false, - "sleeve_condition": "Very Good Plus (VG+)", - "id": 118549560, - "condition": "Very Good Plus (VG+)", - "posted": "2013-09-16T17:54:23", - "ships_from": "United States", - "uri": "http://www.discogs.com/sell/item/118549560", - "comments": "", - "seller": { - "username": "360Vinyl", - "resource_url": "https://api.discogs.com/users/360Vinyl", - "id": 493556 - }, - "release": { - "catalog_number": "EMERG01", - "resource_url": "https://api.discogs.com/releases/1335498", - "year": 2008, - "id": 1335498, - "description": "Invincible (2) - ShapeShifters / Keep Goin' / No Easy Answers (12\", Single)" - }, - "resource_url": "https://api.discogs.com/marketplace/listings/118549560", - "audio": false - } - ] - } -} diff --git a/tests/fixtures/get_label.json b/tests/fixtures/get_label.json deleted file mode 100644 index 587edbb..0000000 --- a/tests/fixtures/get_label.json +++ /dev/null @@ -1,125 +0,0 @@ -{ - "version": 1.1, - "status": 200, - "reason": "OK", - "headers": [ - { "Reproxy-Status": "yes" }, - { "Access-Control-Allow-Origin": "*" }, - { "Cache-Control": "public, must-revalidate" }, - { "Content-Type": "application/json" }, - { "Server": "lighttpd" }, - { "Content-Length": 3140 }, - { "Date": "Tue, 29 Jul 2014 19:51:16 GMT" }, - { "X-Varnish": "1931344079 1931343140" }, - { "Age": 4 }, - { "Via": "1.1 varnish" }, - { "Connection": "close" } - ], - "body": { - "profile": "Classic Techno label from Detroit, USA.\r\n[b]Label owner:[/b] [a=Carl Craig].\r\n", - "releases_url": "https://api.discogs.com/labels/1/releases", - "name": "Planet E", - "contact_info": "Planet E Communications\r\nP.O. Box 27218\r\nDetroit, 48227, USA\r\n\r\np: 313.874.8729 \r\nf: 313.874.8732\r\n\r\nemail: info AT Planet-e DOT net\r\n", - "uri": "http://www.discogs.com/label/1-Planet-E", - "sublabels": [ - { - "resource_url": "https://api.discogs.com/labels/86537", - "id": 86537, - "name": "Antidote (4)" - }, - { - "resource_url": "https://api.discogs.com/labels/41841", - "id": 41841, - "name": "Community Projects" - }, - { - "resource_url": "https://api.discogs.com/labels/153760", - "id": 153760, - "name": "Guilty Pleasures" - }, - { - "resource_url": "https://api.discogs.com/labels/31405", - "id": 31405, - "name": "I Ner Zon Sounds" - }, - { - "resource_url": "https://api.discogs.com/labels/294738", - "id": 294738, - "name": "Planet E Communications, Inc." - }, - { - "resource_url": "https://api.discogs.com/labels/488315", - "id": 488315, - "name": "TWPENTY" - } - ], - "urls": [ - "http://planet-e.net/", - "http://www.facebook.com/planetedetroit", - "http://twitter.com/planetedetroit", - "http://www.flickr.com/photos/planetedetroit/", - "http://myspace.com/planetedetroit", - "http://myspace.com/planetecom", - "http://plus.google.com/100841702106447505236", - "http://www.discogs.com/user/planetedetroit", - "http://en.wikipedia.org/wiki/Planet_E_Communications", - "http://soundcloud.com/planetedetroit", - "http://planetecommunications.bandcamp.com/", - "http://www.youtube.com/user/planetedetroit", - "http://vimeo.com/user1265384" - ], - "images": [ - { - "uri": "https://api.discogs.com/image/L-1-1111053865.png", - "height": 24, - "width": 132, - "resource_url": "https://api.discogs.com/image/L-1-1111053865.png", - "type": "primary", - "uri150": "https://api.discogs.com/image/L-150-1-1111053865.png" - }, - { - "uri": "https://api.discogs.com/image/L-1-1139403654.gif", - "height": 126, - "width": 587, - "resource_url": "https://api.discogs.com/image/L-1-1139403654.gif", - "type": "secondary", - "uri150": "https://api.discogs.com/image/L-150-1-1139403654.gif" - }, - { - "uri": "https://api.discogs.com/image/L-1-1200165078.gif", - "height": 196, - "width": 600, - "resource_url": "https://api.discogs.com/image/L-1-1200165078.gif", - "type": "secondary", - "uri150": "https://api.discogs.com/image/L-150-1-1200165078.gif" - }, - { - "uri": "https://api.discogs.com/image/L-1-1258998163.png", - "height": 121, - "width": 275, - "resource_url": "https://api.discogs.com/image/L-1-1258998163.png", - "type": "secondary", - "uri150": "https://api.discogs.com/image/L-150-1-1258998163.png" - }, - { - "uri": "https://api.discogs.com/image/L-1-1348359908-8971.jpeg", - "height": 720, - "width": 382, - "resource_url": "https://api.discogs.com/image/L-1-1348359908-8971.jpeg", - "type": "secondary", - "uri150": "https://api.discogs.com/image/L-150-1-1348359908-8971.jpeg" - }, - { - "uri": "https://api.discogs.com/image/L-1-1360101583-3643.jpeg", - "height": 189, - "width": 600, - "resource_url": "https://api.discogs.com/image/L-1-1360101583-3643.jpeg", - "type": "secondary", - "uri150": "https://api.discogs.com/image/L-150-1-1360101583-3643.jpeg" - } - ], - "resource_url": "https://api.discogs.com/labels/1", - "id": 1, - "data_quality": "Needs Vote" - } -} diff --git a/tests/fixtures/get_label_releases.json b/tests/fixtures/get_label_releases.json deleted file mode 100644 index d7b92d1..0000000 --- a/tests/fixtures/get_label_releases.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "version": 1.1, - "status": 200, - "reason": "OK", - "headers": [ - { "Reproxy-Status": "yes" }, - { "Access-Control-Allow-Origin": "*" }, - { "Cache-Control": "public, must-revalidate" }, - { "Content-Type": "application/json" }, - { - "Link": "; rel='last', ; rel='next'" - }, - { "Server": "lighttpd" }, - { "Content-Length": 758 }, - { "Date": "Tue, 29 Jul 2014 19:58:12 GMT" }, - { "X-Varnish": 1931448274 }, - { "Age": 0 }, - { "Via": "1.1 varnish" }, - { "Connection": "close" } - ], - "body": { - "pagination": { - "per_page": 2, - "items": 338, - "page": 1, - "urls": { - "last": "https://api.discogs.com/labels/1/releases?per_page=2&page=169", - "next": "https://api.discogs.com/labels/1/releases?per_page=2&page=2" - }, - "pages": 169 - }, - "releases": [ - { - "status": "Accepted", - "thumb": "https://api.discogs.com/image/R-150-2801-1335473692.jpeg", - "format": "CD, Mixed", - "title": "DJ-Kicks", - "catno": "!K7071CD", - "resource_url": "https://api.discogs.com/releases/2801", - "artist": "Andrea Parker", - "id": 2801 - }, - { - "status": "Accepted", - "thumb": "https://api.discogs.com/image/R-150-9922-1220186706.jpeg", - "format": "CD, Album, P/Mixed", - "title": "Programmed", - "catno": "546 137-2", - "resource_url": "https://api.discogs.com/releases/9922", - "artist": "Innerzone Orchestra", - "id": 9922 - } - ] - } -} diff --git a/tests/fixtures/get_master.json b/tests/fixtures/get_master.json deleted file mode 100644 index f8c76e7..0000000 --- a/tests/fixtures/get_master.json +++ /dev/null @@ -1,224 +0,0 @@ -{ - "version": 1.1, - "status": 200, - "reason": "OK", - "headers": [ - { "Reproxy-Status": "yes" }, - { "Access-Control-Allow-Origin": "*" }, - { "Cache-Control": "public, must-revalidate" }, - { "Content-Type": "application/json" }, - { "Server": "lighttpd" }, - { "Content-Length": 4875 }, - { "Date": "Tue, 29 Jul 2014 19:35:39 GMT" }, - { "X-Varnish": 1931104915 }, - { "Age": 0 }, - { "Via": "1.1 varnish" }, - { "Connection": "close" } - ], - "body": { - "styles": [ "Techno", "Euro House" ], - "genres": [ "Electronic" ], - "videos": [ - { - "duration": 291, - "description": "Apotheosis - O Fortuna", - "embed": true, - "uri": "http://www.youtube.com/watch?v=ijm8LL42WTE", - "title": "Apotheosis - O Fortuna" - }, - { - "duration": 291, - "description": "Apotheosis - O Fortuna", - "embed": true, - "uri": "http://www.youtube.com/watch?v=8m4RAjgfm3U", - "title": "Apotheosis - O Fortuna" - }, - { - "duration": 292, - "description": "Apotheosis - O Fortuna (Apocalypse Chorus Mix) [HQ]", - "embed": true, - "uri": "http://www.youtube.com/watch?v=61R_TcsLAnM", - "title": "Apotheosis - O Fortuna (Apocalypse Chorus Mix) [HQ]" - }, - { - "duration": 293, - "description": "Apotheosis - O Fortuna (Apocalypse Chorus Mix) (A)", - "embed": true, - "uri": "http://www.youtube.com/watch?v=i02UPBrAC-Y", - "title": "Apotheosis - O Fortuna (Apocalypse Chorus Mix) (A)" - }, - { - "duration": 430, - "description": "APOTHEOSIS An Other Thing (Metropoll Sky Undermix)", - "embed": true, - "uri": "http://www.youtube.com/watch?v=LHpbcrspol8", - "title": "APOTHEOSIS An Other Thing (Metropoll Sky Undermix)" - }, - { - "duration": 436, - "description": "Apotheosis - An Other Thing (Metropoll Sky Undermix) (B)", - "embed": true, - "uri": "http://www.youtube.com/watch?v=ZrFIFvZ9YhA", - "title": "Apotheosis - An Other Thing (Metropoll Sky Undermix) (B)" - }, - { - "duration": 435, - "description": "APOTHEOSIS B1 An Other Thing (Metropoll Sky UnderMix)", - "embed": true, - "uri": "http://www.youtube.com/watch?v=4OWrQvYSdyA", - "title": "APOTHEOSIS B1 An Other Thing (Metropoll Sky UnderMix)" - }, - { - "duration": 291, - "description": "Apotheosis - o fortuna (apocalypse chorus mix) (1991)", - "embed": true, - "uri": "http://www.youtube.com/watch?v=iAmpVaFIzDQ", - "title": "Apotheosis - o fortuna (apocalypse chorus mix) (1991)" - } - ], - "title": "O Fortuna", - "main_release": 42575, - "main_release_url": "https://api.discogs.com/releases/42575", - "uri": "http://www.discogs.com/Apotheosis-O-Fortuna/master/33687", - "artists": [ - { - "join": "", - "name": "Apotheosis", - "anv": "", - "tracks": "", - "role": "", - "resource_url": "https://api.discogs.com/artists/7678", - "id": 7678 - } - ], - "versions_url": "https://api.discogs.com/masters/33687/versions", - "year": 1991, - "images": [ - { - "uri": "https://api.discogs.com/image/R-42575-1225200540.jpeg", - "height": 592, - "width": 600, - "resource_url": "https://api.discogs.com/image/R-42575-1225200540.jpeg", - "type": "primary", - "uri150": "https://api.discogs.com/image/R-150-42575-1225200540.jpeg" - }, - { - "uri": "https://api.discogs.com/image/R-42575-1225200548.jpeg", - "height": 602, - "width": 600, - "resource_url": "https://api.discogs.com/image/R-42575-1225200548.jpeg", - "type": "secondary", - "uri150": "https://api.discogs.com/image/R-150-42575-1225200548.jpeg" - }, - { - "uri": "https://api.discogs.com/image/R-42575-1225014901.jpeg", - "height": 603, - "width": 600, - "resource_url": "https://api.discogs.com/image/R-42575-1225014901.jpeg", - "type": "secondary", - "uri150": "https://api.discogs.com/image/R-150-42575-1225014901.jpeg" - }, - { - "uri": "https://api.discogs.com/image/R-42575-1225014906.jpeg", - "height": 606, - "width": 600, - "resource_url": "https://api.discogs.com/image/R-42575-1225014906.jpeg", - "type": "secondary", - "uri150": "https://api.discogs.com/image/R-150-42575-1225014906.jpeg" - } - ], - "resource_url": "https://api.discogs.com/masters/33687", - "tracklist": [ - { - "duration": "5:10", - "position": "A", - "type_": "track", - "extraartists": [ - { - "join": "", - "name": "Luc Rigaux", - "anv": "", - "tracks": "", - "role": "Producer [Re-produced], Arranged By [Re-arranged]", - "resource_url": "https://api.discogs.com/artists/111631", - "id": 111631 - }, - { - "join": "", - "name": "Patrick Samoy", - "anv": "", - "tracks": "", - "role": "Producer [Re-produced], Arranged By [Re-arranged]", - "resource_url": "https://api.discogs.com/artists/111632", - "id": 111632 - }, - { - "join": "", - "name": "Carl Orff", - "anv": "", - "tracks": "", - "role": "Written-By", - "resource_url": "https://api.discogs.com/artists/102506", - "id": 102506 - } - ], - "title": "O Fortuna (Apocalypse Chorus Mix)" - }, - { - "duration": "7:15", - "position": "B", - "type_": "track", - "extraartists": [ - { - "join": "", - "name": "Ronald Stock", - "anv": "", - "tracks": "", - "role": "Engineer", - "resource_url": "https://api.discogs.com/artists/721079", - "id": 721079 - }, - { - "join": "", - "name": "Steve Humby", - "anv": "", - "tracks": "", - "role": "Engineer", - "resource_url": "https://api.discogs.com/artists/671092", - "id": 671092 - }, - { - "join": "", - "name": "Empire Control", - "anv": "", - "tracks": "", - "role": "Producer", - "resource_url": "https://api.discogs.com/artists/171429", - "id": 171429 - }, - { - "join": "", - "name": "Luc Rigaux", - "anv": "L. Rigaux", - "tracks": "", - "role": "Written-By", - "resource_url": "https://api.discogs.com/artists/111631", - "id": 111631 - }, - { - "join": "", - "name": "Patrick Samoy", - "anv": "P. Samoy", - "tracks": "", - "role": "Written-By", - "resource_url": "https://api.discogs.com/artists/111632", - "id": 111632 - } - ], - "title": "An Other Thing (Metropoll Sky UnderMix)" - } - ], - "id": 33687, - "data_quality": "Correct" - } -} diff --git a/tests/fixtures/get_master_versions.json b/tests/fixtures/get_master_versions.json deleted file mode 100644 index 1ed29a6..0000000 --- a/tests/fixtures/get_master_versions.json +++ /dev/null @@ -1,85 +0,0 @@ -{ - "version": 1.1, - "status": 200, - "reason": "OK", - "headers": [ - { "Reproxy-Status": "yes" }, - { "Access-Control-Allow-Origin": "*" }, - { "Cache-Control": "public, must-revalidate" }, - { "Content-Type": "application/json" }, - { - "Link": "; rel='next', ; rel='prev', ; rel='last', ; rel='first'" - }, - { "Server": "lighttpd" }, - { "Content-Length": 1584 }, - { "Date": "Tue, 29 Jul 2014 19:47:44 GMT" }, - { "X-Varnish": 1931290184 }, - { "Age": 0 }, - { "Via": "1.1 varnish" }, - { "Connection": "close" } - ], - "body": { - "pagination": { - "per_page": 4, - "items": 9, - "page": 2, - "urls": { - "next": "https://api.discogs.com/masters/33687/versions?per_page=4&page=3", - "prev": "https://api.discogs.com/masters/33687/versions?per_page=4&page=1", - "last": "https://api.discogs.com/masters/33687/versions?per_page=4&page=3", - "first": "https://api.discogs.com/masters/33687/versions?per_page=4&page=1" - }, - "pages": 3 - }, - "versions": [ - { - "status": "Accepted", - "thumb": "https://api.discogs.com/image/R-150-1418157-1218011131.jpeg", - "title": "O Fortuna", - "country": "Belgium", - "format": "Cass, Single", - "label": "Indisc", - "released": "1991", - "catno": "300170-4", - "resource_url": "https://api.discogs.com/releases/1418157", - "id": 1418157 - }, - { - "status": "Accepted", - "thumb": "https://api.discogs.com/image/R-150-179412-1291392874.jpeg", - "title": "O Fortuna", - "country": "Germany", - "format": "12\"", - "label": "ZYX Records", - "released": "1992", - "catno": "ZYX 6743-12", - "resource_url": "https://api.discogs.com/releases/179412", - "id": 179412 - }, - { - "status": "Accepted", - "thumb": "https://api.discogs.com/image/R-150-84503-1205531119.jpeg", - "title": "O Fortuna", - "country": "Belgium", - "format": "7\", Single", - "label": "Indisc", - "released": "1991", - "catno": "DIS 8319", - "resource_url": "https://api.discogs.com/releases/84503", - "id": 84503 - }, - { - "status": "Accepted", - "thumb": "https://api.discogs.com/image/R-150-307198-1195545301.jpeg", - "title": "O Fortuna", - "country": "US", - "format": "CD, Single, Car", - "label": "Radikal Records", - "released": "1992", - "catno": "CDS 12299-2", - "resource_url": "https://api.discogs.com/releases/307198", - "id": 307198 - } - ] - } -} diff --git a/tests/fixtures/get_oauth_identity.json b/tests/fixtures/get_oauth_identity.json deleted file mode 100644 index 4e0a782..0000000 --- a/tests/fixtures/get_oauth_identity.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "version": 1.1, - "status": 200, - "reason": "OK", - "headers": [ - { "Reproxy-Status": "yes" }, - { "Access-Control-Allow-Origin": "*" }, - { "Cache-Control": "public, must-revalidate" }, - { "Content-Type": "application/json" }, - { "Server": "lighttpd" }, - { "Content-Length": 135 }, - { "Date": "Thu, 31 Jul 2014 20:14:35 GMT" }, - { "X-Varnish": 1965254583 }, - { "Age": 0 }, - { "Via": "1.1 varnish" }, - { "Connection": "keep-alive" } - ], - "body": { - "username": "R-Search", - "resource_url": "https://api.discogs.com/users/R-Search", - "consumer_name": "RicbraDiscogsBundle", - "id": 557870 - } -} diff --git a/tests/fixtures/get_order.json b/tests/fixtures/get_order.json deleted file mode 100644 index 03a5df4..0000000 --- a/tests/fixtures/get_order.json +++ /dev/null @@ -1,76 +0,0 @@ -{ - "version": 1.1, - "status": 200, - "reason": "OK", - "headers": [ - { "Reproxy-Status": "yes" }, - { "Access-Control-Allow-Origin": "*" }, - { "Cache-Control": "public, must-revalidate" }, - { "Content-Type": "application/json" }, - { "Server": "lighttpd" }, - { "Content-Length": 780 }, - { "Date": "Tue, 15 Jul 2014 19:59:59 GMT" }, - { "X-Varnish": 1702965334 }, - { "Age": 0 }, - { "Via": "1.1 varnish" }, - { "Connection": "keep-alive" } - ], - "body": { - "id": "1-1", - "resource_url": "https://api.discogs.com/marketplace/orders/1-1", - "messages_url": "https://api.discogs.com/marketplace/orders/1-1/messages", - "uri": "http://www.discogs.com/sell/order/1-1", - "status": "New Order", - "next_status": [ - "New Order", - "Buyer Contacted", - "Invoice Sent", - "Payment Pending", - "Payment Received", - "Shipped", - "Refund Sent", - "Cancelled (Non-Paying Buyer)", - "Cancelled (Item Unavailable)", - "Cancelled (Per Buyer's Request)" - ], - "fee": { - "currency": "USD", - "value": 2.52 - }, - "created": "2011-10-21T09:25:17", - "items": [ - { - "release": { - "id": 1, - "description": "Persuader, The - Stockholm (2x12\")" - }, - "price": { - "currency": "USD", - "value": 42.0 - }, - "id": 41578242 - } - ], - "shipping": { - "currency": "USD", - "value": 0.0 - }, - "shipping_address": "Asdf Exampleton\n234 NE Asdf St.\nAsdf Town, Oregon, 14423\nUnited States\n\nPhone: 555-555-2733\nPaypal address: asdf@example.com", - "additional_instructions": "please use sturdy packaging.", - "seller": { - "resource_url": "https://api.discogs.com/users/example_seller", - "username": "example_seller", - "id": 1 - }, - "last_activity": "2011-10-21T09:25:17", - "buyer": { - "resource_url": "https://api.discogs.com/users/example_buyer", - "username": "example_buyer", - "id": 2 - }, - "total": { - "currency": "USD", - "value": 42.0 - } - } -} diff --git a/tests/fixtures/get_orders.json b/tests/fixtures/get_orders.json deleted file mode 100644 index f558ace..0000000 --- a/tests/fixtures/get_orders.json +++ /dev/null @@ -1,87 +0,0 @@ -{ - "version": 1.1, - "status": 200, - "reason": "OK", - "headers": [ - { "Reproxy-Status": "yes" }, - { "Access-Control-Allow-Origin": "*" }, - { "Cache-Control": "public, must-revalidate" }, - { "Content-Type": "application/json" }, - { "Server": "lighttpd" }, - { "Content-Length": 780 }, - { "Date": "Tue, 15 Jul 2014 19:59:59 GMT" }, - { "X-Varnish": 1702965334 }, - { "Age": 0 }, - { "Via": "1.1 varnish" }, - { "Connection": "keep-alive" } - ], - "body": { - "pagination": { - "per_page": 50, - "pages": 1, - "page": 1, - "items": 1, - "urls": {} - }, - "orders": [ - { - "status": "New Order", - "fee": { - "currency": "USD", - "value": 2.52 - }, - "created": "2011-10-21T09:25:17", - "items": [ - { - "release": { - "id": 1, - "description": "Persuader, The - Stockholm (2x12\")" - }, - "price": { - "currency": "USD", - "value": 42.0 - }, - "id": 41578242 - } - ], - "shipping": { - "currency": "USD", - "value": 0.0 - }, - "shipping_address": "Asdf Exampleton\n234 NE Asdf St.\nAsdf Town, Oregon, 14423\nUnited States\n\nPhone: 555-555-2733\nPaypal address: asdf@example.com", - "additional_instructions": "please use sturdy packaging.", - "seller": { - "resource_url": "https://api.discogs.com/users/example_seller", - "username": "example_seller", - "id": 1 - }, - "last_activity": "2011-10-21T09:25:17", - "buyer": { - "resource_url": "https://api.discogs.com/users/example_buyer", - "username": "example_buyer", - "id": 2 - }, - "total": { - "currency": "USD", - "value": 42.0 - }, - "id": "1-1", - "resource_url": "https://api.discogs.com/marketplace/orders/1-1", - "messages_url": "https://api.discogs.com/marketplace/orders/1-1/messages", - "uri": "http://www.discogs.com/sell/order/1-1", - "next_status": [ - "New Order", - "Buyer Contacted", - "Invoice Sent", - "Payment Pending", - "Payment Received", - "Shipped", - "Refund Sent", - "Cancelled (Non-Paying Buyer)", - "Cancelled (Item Unavailable)", - "Cancelled (Per Buyer's Request)" - ] - } - ] - } -} diff --git a/tests/fixtures/get_profile.json b/tests/fixtures/get_profile.json deleted file mode 100644 index 4297447..0000000 --- a/tests/fixtures/get_profile.json +++ /dev/null @@ -1,50 +0,0 @@ -{ - "version": 1.1, - "status": 200, - "reason": "OK", - "headers": [ - { "Reproxy-Status": "yes" }, - { "Access-Control-Allow-Origin": "*" }, - { "Cache-Control": "public, must-revalidate" }, - { "Content-Type": "application/json" }, - { "Server": "lighttpd" }, - { "Content-Length": 858 }, - { "Date": "Thu, 31 Jul 2014 20:14:35 GMT" }, - { "X-Varnish": 1718492795 }, - { "Age": 0 }, - { "Via": "1.1 varnish" }, - { "Connection": "keep-alive" } - ], - "body": { - "profile": "", - "banner_url": "", - "wantlist_url": "https://api.discogs.com/users/maxperei/wants", - "seller_num_ratings": 13, - "rank": 57.0, - "num_pending": 6, - "id": 1861520, - "buyer_rating": 100.0, - "num_for_sale": 63, - "home_page": "http://maxperei.info", - "location": "France", - "collection_folders_url": "https://api.discogs.com/users/maxperei/collection/folders", - "username": "maxperei", - "collection_fields_url": "https://api.discogs.com/users/maxperei/collection/fields", - "releases_contributed": 7, - "registered": "2013-05-26T05:59:09-07:00", - "rating_avg": 4.48, - "num_collection": 414, - "releases_rated": 46, - "curr_abbr": "", - "seller_rating_stars": 5.0, - "num_lists": 0, - "name": "\u2234", - "buyer_rating_stars": 5.0, - "inventory_url": "https://api.discogs.com/users/maxperei/inventory", - "uri": "https://www.discogs.com/user/maxperei", - "buyer_num_ratings": 32, - "avatar_url": "https://img.discogs.com/mDaw_OUjHspYLj77C_tcobr2eXc=/500x500/filters:strip_icc():format(jpeg):quality(40)/discogs-avatars/U-1861520-1498224434.jpeg.jpg", - "resource_url": "https://api.discogs.com/users/maxperei", - "seller_rating": 100.0 - } -} diff --git a/tests/fixtures/get_release.json b/tests/fixtures/get_release.json deleted file mode 100644 index 588660d..0000000 --- a/tests/fixtures/get_release.json +++ /dev/null @@ -1,248 +0,0 @@ -{ - "version": 1.1, - "status": 200, - "reason": "OK", - "headers": [ - { "Reproxy-Status": "yes" }, - { "Access-Control-Allow-Origin": "*" }, - { "Cache-Control": "public, must-revalidate" }, - { "Content-Type": "application/json" }, - { "Server": "lighttpd" }, - { "Content-Length": 6299 }, - { "Date": "Tue, 29 Jul 2014 19:20:12 GMT" }, - { "X-Varnish": 1930870444 }, - { "Age": 0 }, - { "Via": "1.1 varnish" }, - { "Connection": "close" } - ], - "body": { - "status": "Accepted", - "styles": [ "Deep House" ], - "videos": [ - { - "duration": 380, - "description": "The Persuader - Vasastaden", - "embed": true, - "uri": "http://www.youtube.com/watch?v=5rA8CTKKEP4", - "title": "The Persuader - Vasastaden" - }, - { - "duration": 335, - "description": "The Persuader-Stockholm-Sodermalm", - "embed": true, - "uri": "http://www.youtube.com/watch?v=QVdDhOnoR8k", - "title": "The Persuader-Stockholm-Sodermalm" - }, - { - "duration": 290, - "description": "The Persuader (Jesper Dahlb\u00e4ck) - \u00d6stermalm", - "embed": true, - "uri": "http://www.youtube.com/watch?v=AHuQWcylaU4", - "title": "The Persuader (Jesper Dahlb\u00e4ck) - \u00d6stermalm" - }, - { - "duration": 175, - "description": "The Persuader - Kungsholmen - Svek Records", - "embed": true, - "uri": "http://www.youtube.com/watch?v=sLZvvJVir5g", - "title": "The Persuader - Kungsholmen - Svek Records" - }, - { - "duration": 324, - "description": "The Persuader - Gamla Stan - Svek Records", - "embed": true, - "uri": "http://www.youtube.com/watch?v=js_g1qtPmL0", - "title": "The Persuader - Gamla Stan - Svek Records" - }, - { - "duration": 289, - "description": "The Persuader - Norrmalm", - "embed": true, - "uri": "http://www.youtube.com/watch?v=hy47qgyJeG0", - "title": "The Persuader - Norrmalm" - } - ], - "series": [], - "released_formatted": "Mar 1999", - "labels": [ - { - "id": 5, - "resource_url": "https://api.discogs.com/labels/5", - "catno": "SK032", - "name": "Svek", - "entity_type": "1" - } - ], - "estimated_weight": 460, - "community": { - "status": "Accepted", - "rating": { "count": 95, "average": 4.48 }, - "have": 319, - "contributors": [ - { "username": "teo", "resource_url": "https://api.discogs.com/users/teo" }, - { - "username": "Ultravod", - "resource_url": "https://api.discogs.com/users/Ultravod" - }, - { "username": "MONK", "resource_url": "https://api.discogs.com/users/MONK" }, - { - "username": "herr_roessi", - "resource_url": "https://api.discogs.com/users/herr_roessi" - }, - { - "username": "Swedens_Finest", - "resource_url": "https://api.discogs.com/users/Swedens_Finest" - }, - { - "username": "daniel-phonk", - "resource_url": "https://api.discogs.com/users/daniel-phonk" - }, - { - "username": "sharevari", - "resource_url": "https://api.discogs.com/users/sharevari" - }, - { - "username": "cosmicdream", - "resource_url": "https://api.discogs.com/users/cosmicdream" - }, - { "username": "moob", "resource_url": "https://api.discogs.com/users/moob" }, - { - "username": "irionman", - "resource_url": "https://api.discogs.com/users/irionman" - }, - { - "username": "PabloPlato", - "resource_url": "https://api.discogs.com/users/PabloPlato" - }, - { "username": "tomazy", "resource_url": "https://api.discogs.com/users/tomazy" }, - { - "username": "NeedleCain", - "resource_url": "https://api.discogs.com/users/NeedleCain" - }, - { - "username": "more_music", - "resource_url": "https://api.discogs.com/users/more_music" - }, - { "username": "teori", "resource_url": "https://api.discogs.com/users/teori" }, - { - "username": "disneyfacts", - "resource_url": "https://api.discogs.com/users/disneyfacts" - }, - { "username": "baczbad", "resource_url": "https://api.discogs.com/users/baczbad" } - ], - "want": 347, - "submitter": { "username": "teo", "resource_url": "https://api.discogs.com/users/teo" }, - "data_quality": "Complete and Correct" - }, - "released": "1999-03-00", - "master_url": "https://api.discogs.com/masters/5427", - "year": 1999, - "images": [ - { - "uri": "https://api.discogs.com/image/R-1-1193812031.jpeg", - "height": 600, - "width": 600, - "resource_url": "https://api.discogs.com/image/R-1-1193812031.jpeg", - "type": "primary", - "uri150": "https://api.discogs.com/image/R-150-1-1193812031.jpeg" - }, - { - "uri": "https://api.discogs.com/image/R-1-1193812053.jpeg", - "height": 600, - "width": 600, - "resource_url": "https://api.discogs.com/image/R-1-1193812053.jpeg", - "type": "secondary", - "uri150": "https://api.discogs.com/image/R-150-1-1193812053.jpeg" - }, - { - "uri": "https://api.discogs.com/image/R-1-1193812072.jpeg", - "height": 600, - "width": 600, - "resource_url": "https://api.discogs.com/image/R-1-1193812072.jpeg", - "type": "secondary", - "uri150": "https://api.discogs.com/image/R-150-1-1193812072.jpeg" - }, - { - "uri": "https://api.discogs.com/image/R-1-1193812091.jpeg", - "height": 600, - "width": 600, - "resource_url": "https://api.discogs.com/image/R-1-1193812091.jpeg", - "type": "secondary", - "uri150": "https://api.discogs.com/image/R-150-1-1193812091.jpeg" - } - ], - "date_added": "2000-09-01T00:00:00", - "format_quantity": 2, - "id": 1, - "genres": [ "Electronic" ], - "thumb": "https://api.discogs.com/image/R-150-1-1193812031.jpeg", - "extraartists": [ - { - "join": "", - "name": "Jesper Dahlb\u00e4ck", - "anv": "", - "tracks": "", - "role": "Music By [All Tracks By]", - "resource_url": "https://api.discogs.com/artists/239", - "id": 239 - } - ], - "title": "Stockholm", - "country": "Sweden", - "notes": "The song titles are the names of Stockholm's districts.\r\n", - "identifiers": [ - { - "type": "Matrix / Runout", - "description": "A-Side", - "value": "MPO SK 032 A1 G PHRUPMASTERGENERAL T27 LONDON" - }, - { "type": "Matrix / Runout", "description": "B-Side", "value": "MPO SK 032 B1" }, - { "type": "Matrix / Runout", "description": "C-Side", "value": "MPO SK 032 C1" }, - { "type": "Matrix / Runout", "description": "D-Side", "value": "MPO SK 032 D1" } - ], - "companies": [ - { - "name": "The Globe Studios", - "entity_type": "23", - "catno": "", - "resource_url": "https://api.discogs.com/labels/271046", - "id": 271046, - "entity_type_name": "Recorded At" - }, - { - "name": "MPO", - "entity_type": "17", - "catno": "", - "resource_url": "https://api.discogs.com/labels/56025", - "id": 56025, - "entity_type_name": "Pressed By" - } - ], - "uri": "http://www.discogs.com/Persuader-Stockholm/release/1", - "artists": [ - { - "join": "", - "name": "Persuader, The", - "anv": "", - "tracks": "", - "role": "", - "resource_url": "https://api.discogs.com/artists/1", - "id": 1 - } - ], - "formats": [ { "descriptions": [ "12\"", "33 \u2153 RPM" ], "name": "Vinyl", "qty": "2" } ], - "date_changed": "2013-01-23T10:53:34", - "lowest_price": 8.077169493076712, - "resource_url": "https://api.discogs.com/releases/1", - "master_id": 5427, - "tracklist": [ - { "duration": "4:45", "position": "A", "type_": "track", "title": "\u00d6stermalm" }, - { "duration": "6:11", "position": "B1", "type_": "track", "title": "Vasastaden" }, - { "duration": "2:49", "position": "B2", "type_": "track", "title": "Kungsholmen" }, - { "duration": "5:38", "position": "C1", "type_": "track", "title": "S\u00f6dermalm" }, - { "duration": "4:52", "position": "C2", "type_": "track", "title": "Norrmalm" }, - { "duration": "5:16", "position": "D", "type_": "track", "title": "Gamla Stan" } - ], - "data_quality": "Complete and Correct" - } -} diff --git a/tests/fixtures/search.json b/tests/fixtures/search.json deleted file mode 100644 index dd01149..0000000 --- a/tests/fixtures/search.json +++ /dev/null @@ -1,909 +0,0 @@ -{ - "version": 1.1, - "status": 200, - "reason": "OK", - "headers": [ - { "Reproxy-Status": "yes" }, - { "Access-Control-Allow-Origin": "*" }, - { "Cache-Control": "public, must-revalidate" }, - { "Content-Type": "application/json" }, - { - "Link": "; rel='last', ; rel='next'" - }, - { "Server": "lighttpd" }, - { "Content-Length": 28159 }, - { "Date": "Mon, 28 Jul 2014 20:20:20 GMT" }, - { "X-Varnish": 1915383613 }, - { "Age": 0 }, - { "Via": "1.1 varnish" }, - { "Connection": "close" } - ], - "body": { - "pagination": { - "per_page": 50, - "items": 5661, - "page": 1, - "urls": { - "last": "https://api.discogs.com/database/search?q=prodigy&per_page=50&type=release&page=114", - "next": "https://api.discogs.com/database/search?q=prodigy&per_page=50&type=release&page=2" - }, - "pages": 114 - }, - "results": [ - { - "style": [ "Breakbeat", "Big Beat" ], - "thumb": "https://api.discogs.com/image/R-90-2734257-1298624566.jpeg", - "format": [ "CD", "Compilation", "Unofficial Release" ], - "country": "", - "barcode": [ "4189883331725" ], - "uri": "/Prodigy-The-RestUnreleased-The-Last/release/2734257", - "community": { "have": 6, "want": 8 }, - "label": [ "Not On Label (The Prodigy)" ], - "catno": "PRODIGY 2811685", - "year": "1997", - "genre": [ "Electronic" ], - "title": "Prodigy, The - The Rest, The Unreleased! The Last", - "resource_url": "https://api.discogs.com/releases/2734257", - "type": "release", - "id": 2734257 - }, - { - "style": [ "Breakbeat", "Broken Beat", "Happy Hardcore", "Big Beat", "Hardcore" ], - "thumb": "https://api.discogs.com/image/R-90-4251474-1359751198-2821.jpeg", - "format": [ "CD", "CD-ROM", "Compilation", "Unofficial Release" ], - "country": "Russia", - "barcode": [ "Fly Multimedia 0012" ], - "uri": "/Prodigy-Prodigy/release/4251474", - "community": { "have": 1, "want": 3 }, - "label": [ "Fly Multimedia", "Not On Label (The Prodigy)" ], - "catno": "none", - "year": "1998", - "genre": [ "Electronic" ], - "title": "Prodigy* - Prodigy", - "resource_url": "https://api.discogs.com/releases/4251474", - "type": "release", - "id": 4251474 - }, - { - "style": [ "Breakbeat", "Broken Beat", "Happy Hardcore", "Big Beat", "Hardcore" ], - "thumb": "https://api.discogs.com/image/R-90-2858963-1333827232.jpeg", - "format": [ "CD", "CD-ROM", "Compilation", "Unofficial Release" ], - "country": "Russia", - "barcode": [ "FFF 019905" ], - "uri": "/Prodigy-Prodigy/release/2858963", - "community": { "have": 3, "want": 11 }, - "label": [ "Not On Label (The Prodigy)" ], - "catno": "none", - "year": "1999", - "genre": [ "Electronic" ], - "title": "Prodigy* - Prodigy", - "resource_url": "https://api.discogs.com/releases/2858963", - "type": "release", - "id": 2858963 - }, - { - "style": [ "Breakbeat", "Broken Beat", "Happy Hardcore", "Big Beat", "Hardcore" ], - "thumb": "https://api.discogs.com/image/R-90-4847483-1377443897-3420.jpeg", - "format": [ "CD", "CD-ROM", "Compilation", "Unofficial Release" ], - "country": "Russia", - "barcode": [ "6 456489 431561", "6456489431561", "MP3 12/2007/64" ], - "uri": "/Prodigy-Prodigy/release/4847483", - "community": { "have": 1, "want": 2 }, - "label": [ - "\u041c\u043e\u043d\u043e \u0426\u0435\u043d\u0442\u0440", - "\u041e\u041e\u041e \"\u041c\u043e\u043d\u043e \u0426\u0435\u043d\u0442\u0440\"" - ], - "catno": "R CD GR0054", - "year": "2008", - "genre": [ "Electronic" ], - "title": "Prodigy* - Prodigy", - "resource_url": "https://api.discogs.com/releases/4847483", - "type": "release", - "id": 4847483 - }, - { - "style": [ "Progressive House", "Electro" ], - "thumb": "https://api.discogs.com/image/R-90-1463583-1221644150.jpeg", - "format": [ "Vinyl", "12\"", "Single Sided", "White Label", "Unofficial Release" ], - "country": "UK", - "title": "Prodigy, The - Everybody In The Place (Deadhau5 Mix)", - "uri": "/Prodigy-Everybody-In-The-Place-Deadhau5-Mix/release/1463583", - "community": { "have": 34, "want": 17 }, - "label": [ "Not On Label (The Prodigy)" ], - "catno": "PRODIGY001", - "year": "2008", - "genre": [ "Electronic" ], - "resource_url": "https://api.discogs.com/releases/1463583", - "type": "release", - "id": 1463583 - }, - { - "style": [ "Breakbeat", "Big Beat" ], - "thumb": "https://api.discogs.com/image/R-90-3308604-1325101682.jpeg", - "format": [ "CD", "Compilation", "Enhanced", "Unofficial Release" ], - "country": "Russia", - "barcode": [ "634407225547", "LXZ PRODIGY 2002" ], - "uri": "/Prodigy-Babys-Got-A-Temper-Best-2002/release/3308604", - "community": { "have": 2, "want": 0 }, - "label": [ "Not On Label (The Prodigy)", "Unknown (LXZ)" ], - "catno": "none", - "year": "2002", - "genre": [ "Electronic" ], - "title": "Prodigy, The - Babys Got A Temper [Best 2002]", - "resource_url": "https://api.discogs.com/releases/3308604", - "type": "release", - "id": 3308604 - }, - { - "style": [ "House" ], - "thumb": "https://api.discogs.com/image/R-90-698434-1185372479.jpeg", - "format": [ "Vinyl", "12\"" ], - "country": "US", - "title": "Angel Alanis & Jon Pegnato - Digital Prodigy", - "uri": "/Angel-Alanis--Jon-Pegnato-Digital-Prodigy/release/698434", - "community": { "have": 9, "want": 6 }, - "label": [ "A-Trax" ], - "catno": "ATX 155", - "year": "2006", - "genre": [ "Electronic" ], - "resource_url": "https://api.discogs.com/releases/698434", - "type": "release", - "id": 698434 - }, - { - "style": [ "Hard House" ], - "thumb": "https://api.discogs.com/image/R-90-1121096-1193692208.jpeg", - "format": [ "Vinyl", "12\"" ], - "country": "UK", - "title": "DJ Nemesis (4) vs Klubrockers - Prodigy Beat", - "uri": "/DJ-Nemesis-4-Vs-Klubrockers-Prodigy-Beat/release/1121096", - "community": { "have": 11, "want": 7 }, - "label": [ "Full Force Records" ], - "catno": "FFR001", - "year": "2007", - "genre": [ "Electronic" ], - "resource_url": "https://api.discogs.com/releases/1121096", - "type": "release", - "id": 1121096 - }, - { - "style": [ "Progressive House", "House", "Electro" ], - "thumb": "https://api.discogs.com/images/default-release.png", - "format": [ "File", "MP3", "Single" ], - "country": "UK, Europe & US", - "title": "Jon Kennedy (2) - House Prodigy", - "uri": "/Jon-Kennedy-House-Prodigy/release/1577778", - "community": { "have": 0, "want": 0 }, - "label": [ "Immense Recordings" ], - "catno": "IR021", - "year": "2006", - "genre": [ "Electronic" ], - "resource_url": "https://api.discogs.com/releases/1577778", - "type": "release", - "id": 1577778 - }, - { - "style": [ "House" ], - "thumb": "https://api.discogs.com/images/default-release.png", - "format": [ "Vinyl", "White Label" ], - "country": "US", - "title": "Angel Alanis & Jon Pegnato - Digital Prodigy", - "uri": "/Angel-Alanis-Jon-Pegnato-Digital-Prodigy/release/2299855", - "community": { "have": 2, "want": 0 }, - "label": [ "A-Trax" ], - "catno": "ATX 155", - "year": "2006", - "genre": [ "Electronic" ], - "resource_url": "https://api.discogs.com/releases/2299855", - "type": "release", - "id": 2299855 - }, - { - "style": [ "Electro", "House" ], - "thumb": "https://api.discogs.com/image/R-90-3949274-1350226049-7843.jpeg", - "format": [ "File", "MP3" ], - "country": "US", - "title": "ZXX & Jon Kennedy (2) - House Prodigy ", - "uri": "/ZXX-Jon-Kennedy-House-Prodigy-/release/3949274", - "community": { "have": 0, "want": 0 }, - "label": [ "Munchie Recordings", "Direct Drive Digital", "Direct Drive Digital" ], - "catno": "MR-027", - "year": "2009", - "genre": [ "Electronic" ], - "resource_url": "https://api.discogs.com/releases/3949274", - "type": "release", - "id": 3949274 - }, - { - "style": [ "Bluegrass", "Folk Rock" ], - "thumb": "https://api.discogs.com/image/R-90-4303290-1361249752-7017.jpeg", - "format": [ "Vinyl", "LP", "Album", "Stereo" ], - "country": "US", - "barcode": [ "None", "None", "NR 6450-1 JE", "NR 6450-2 JE", "ASCAP" ], - "uri": "/Mike-Cross-Child-Prodigy/release/4303290", - "community": { "have": 7, "want": 1 }, - "label": [ "TGS Records", "TGS Records", "TGS Studios" ], - "catno": "001-TGS", - "year": "1975", - "genre": [ "Rock", "Folk, World, & Country" ], - "title": "Michael Cross* - Child Prodigy", - "resource_url": "https://api.discogs.com/releases/4303290", - "type": "release", - "id": 4303290 - }, - { - "style": [ "Bluegrass" ], - "thumb": "https://api.discogs.com/image/R-90-3221087-1321075852.jpeg", - "format": [ "Vinyl", "LP", "Album", "Reissue" ], - "country": "US", - "title": "Mike Cross (4) - Child Prodigy", - "uri": "/Mike-Cross-Child-Prodigy/release/3221087", - "community": { "have": 9, "want": 1 }, - "label": [ "GHE Records" ], - "catno": "GR1001", - "year": "1979", - "genre": [ "Folk, World, & Country" ], - "resource_url": "https://api.discogs.com/releases/3221087", - "type": "release", - "id": 3221087 - }, - { - "style": [ "Breakbeat", "Techno", "Big Beat" ], - "thumb": "https://api.discogs.com/image/R-90-2789331-1335442389.jpeg", - "format": [ "CD", "CD-ROM", "Compilation", "Unofficial Release" ], - "country": "Russia", - "barcode": [ "MP-0648" ], - "uri": "/Prodigy-The-Prodigy/release/2789331", - "community": { "have": 5, "want": 4 }, - "label": [ - "\u0414\u043e\u043c\u0430\u0448\u043d\u044f\u044f \u041a\u043e\u043b\u043b\u0435\u043a\u0446\u0438\u044f" - ], - "catno": "MP-0648", - "year": "2002", - "genre": [ "Electronic" ], - "title": "Prodigy, The - The Prodigy", - "resource_url": "https://api.discogs.com/releases/2789331", - "type": "release", - "id": 2789331 - }, - { - "style": [ "Breakbeat", "Big Beat" ], - "thumb": "https://api.discogs.com/image/R-90-2890911-1305917402.jpeg", - "format": [ "Cassette", "Compilation", "Unofficial Release" ], - "country": "Russia", - "barcode": [ "4 601055 040797", "4601055040797" ], - "uri": "/Prodigy-The-Prodigy/release/2890911", - "community": { "have": 1, "want": 8 }, - "label": [ "Godzi Records" ], - "catno": "GR056/09~12", - "year": "2001", - "genre": [ "Electronic" ], - "title": "Prodigy, The - The Prodigy", - "resource_url": "https://api.discogs.com/releases/2890911", - "type": "release", - "id": 2890911 - }, - { - "style": [ "Breakbeat", "Techno", "Big Beat" ], - "thumb": "https://api.discogs.com/image/R-90-4477318-1365974026-6590.jpeg", - "format": [ "CD", "CD-ROM", "Compilation", "Unofficial Release" ], - "country": "Russia", - "barcode": [ "41291-PRDG", "4 619497 412911", "4619497412911" ], - "uri": "/Prodigy-The-Prodigy/release/4477318", - "community": { "have": 0, "want": 1 }, - "label": [ - "\u0414\u043e\u043c\u0430\u0448\u043d\u044f\u044f \u041a\u043e\u043b\u043b\u0435\u043a\u0446\u0438\u044f", - "\u041d\u0430\u0432\u0438\u0433\u0430\u0442\u043e\u0440", - "\u041e\u041e\u041e \"\u042d\u043b\u043a\u043e\u043c\"" - ], - "catno": "none", - "year": "2004", - "genre": [ "Electronic" ], - "title": "Prodigy, The - The Prodigy", - "resource_url": "https://api.discogs.com/releases/4477318", - "type": "release", - "id": 4477318 - }, - { - "style": [ "Alternative Rock" ], - "thumb": "https://api.discogs.com/image/R-90-1950031-1254989968.jpeg", - "format": [ "Vinyl", "LP", "Album" ], - "country": "US", - "barcode": [ - "7 44861 08871 4", - "OLE-887-1-A 18498.1(3)... PCMTR", - "OLE-887-1-B 18498.2(3)... PCMTR" - ], - "uri": "/Kurt-Vile-Childish-Prodigy/release/1950031", - "community": { "have": 511, "want": 135 }, - "label": [ "Matador" ], - "catno": "OLE-887-1", - "year": "2009", - "genre": [ "Electronic", "Rock", "Blues", "Pop" ], - "title": "Kurt Vile - Childish Prodigy", - "resource_url": "https://api.discogs.com/releases/1950031", - "type": "release", - "id": 1950031 - }, - { - "style": [ "Alternative Rock", "Indie Rock" ], - "thumb": "https://api.discogs.com/image/R-90-2118623-1265046576.jpeg", - "format": [ "CD", "Album" ], - "country": "Europe", - "barcode": [ - "7 44861 08872 1", - "LC 11552", - "IFPI L847", - "IFPI 0781", - "53775823/OLE887-2 21" - ], - "uri": "/Kurt-Vile-Childish-Prodigy/release/2118623", - "community": { "have": 55, "want": 14 }, - "label": [ "Matador" ], - "catno": "OLE-887-2", - "year": "2009", - "genre": [ "Rock" ], - "title": "Kurt Vile - Childish Prodigy", - "resource_url": "https://api.discogs.com/releases/2118623", - "type": "release", - "id": 2118623 - }, - { - "style": [ "Alternative Rock", "Indie Rock" ], - "thumb": "https://api.discogs.com/images/default-release.png", - "format": [ "CDr", "Album", "Promo" ], - "country": "Europe", - "title": "Kurt Vile - Childish Prodigy", - "uri": "/Kurt-Vile-Childish-Prodigy/release/2361537", - "community": { "have": 4, "want": 8 }, - "label": [ "Matador" ], - "catno": "OLE-887-2", - "year": "2009", - "genre": [ "Rock" ], - "resource_url": "https://api.discogs.com/releases/2361537", - "type": "release", - "id": 2361537 - }, - { - "style": [ "Alternative Rock", "Indie Rock" ], - "thumb": "https://api.discogs.com/image/R-90-5765099-1402021805-1873.jpeg", - "format": [ "CD", "Album" ], - "country": "Australia & New Zealand", - "barcode": [ "7 44861 08872 1" ], - "uri": "/Kurt-Vile-Childish-Prodigy/release/5765099", - "community": { "have": 1, "want": 2 }, - "label": [ "Matador", "Inertia", "Rhythmethod", "Remote Control" ], - "catno": "OLE-887-2", - "year": "2009", - "genre": [ "Rock" ], - "title": "Kurt Vile - Childish Prodigy", - "resource_url": "https://api.discogs.com/releases/5765099", - "type": "release", - "id": 5765099 - }, - { - "style": [ "Alternative Rock", "Indie Rock", "Lo-Fi" ], - "thumb": "https://api.discogs.com/image/R-90-5797336-1402938563-7074.jpeg", - "format": [ "CD", "Album" ], - "country": "US", - "barcode": [ - "7 44861 08872 1", - "ifpi L909", - "IFPI 2U9J", - "Z79038 LN OLE 887-2 01" - ], - "uri": "/Kurt-Vile-Childish-Prodigy/release/5797336", - "community": { "have": 2, "want": 2 }, - "label": [ - "Matador", - "Matador", - "Matador", - "West West Side Music", - "Uniform Recording", - "Uniform Recording" - ], - "catno": "OLE-887-2", - "year": "2009", - "genre": [ "Rock" ], - "title": "Kurt Vile - Childish Prodigy", - "resource_url": "https://api.discogs.com/releases/5797336", - "type": "release", - "id": 5797336 - }, - { - "style": [ "Lounge", "Psychedelic Rock" ], - "thumb": "https://api.discogs.com/image/R-90-5467312-1394111845-5599.jpeg", - "format": [ "Vinyl", "LP" ], - "country": "US", - "title": "Prodigy, The (2) - The Prodigy", - "uri": "/Prodigy-The-Prodigy/release/5467312", - "community": { "have": 0, "want": 0 }, - "label": [ "Lariam Associates Inc." ], - "catno": "LAS-121", - "year": "1971", - "genre": [ "Rock" ], - "resource_url": "https://api.discogs.com/releases/5467312", - "type": "release", - "id": 5467312 - }, - { - "style": [ "Breakbeat", "Big Beat" ], - "thumb": "https://api.discogs.com/image/R-90-2844280-1303644632.jpeg", - "format": [ "CD", "Compilation", "Unofficial Release" ], - "country": "Russia", - "barcode": [ "TOR7060" ], - "uri": "/Prodigy-100-Hits-Prodigy-Hits/release/2844280", - "community": { "have": 3, "want": 6 }, - "label": [ "ORZ Records", "100% Hits" ], - "catno": "1010-S97-PRO", - "year": "1997", - "genre": [ "Electronic" ], - "title": "Prodigy* - 100% Hits: Prodigy Hits", - "resource_url": "https://api.discogs.com/releases/2844280", - "type": "release", - "id": 2844280 - }, - { - "thumb": "https://api.discogs.com/image/R-90-4064024-1354031040-8025.jpeg", - "format": [ "Vinyl", "7\"", "45 RPM" ], - "country": "Jamaica", - "title": "Errol Lee & Karen Marr - My Special Smile", - "uri": "/Errol-Lee-Karen-Marr-My-Special-Smile/release/4064024", - "community": { "have": 2, "want": 2 }, - "label": [ "Prodigy" ], - "catno": "none", - "year": "1983", - "genre": [ "Funk / Soul" ], - "resource_url": "https://api.discogs.com/releases/4064024", - "type": "release", - "id": 4064024 - }, - { - "style": [ "Roots Reggae" ], - "thumb": "https://api.discogs.com/image/R-90-4316335-1361555779-9274.jpeg", - "format": [ "Vinyl", "7\"" ], - "country": "Jamaica", - "title": "Tony Tuff - All Day Rockers", - "uri": "/Tony-Tuff-All-Day-Rockers/release/4316335", - "community": { "have": 1, "want": 5 }, - "label": [ "Prodigy" ], - "catno": "none", - "genre": [ "Reggae" ], - "resource_url": "https://api.discogs.com/releases/4316335", - "type": "release", - "id": 4316335 - }, - { - "style": [ "Breakbeat", "Hardcore", "Techno", "Jungle", "Acid" ], - "thumb": "https://api.discogs.com/image/R-90-1504243-1323282869.jpeg", - "format": [ "CD", "Maxi-Single", "Box Set", "Limited Edition", "Compilation" ], - "country": "Poland", - "title": "Prodigy* - 3x Prodigy 2", - "uri": "/Prodigy-3x-Prodigy-2/release/1504243", - "community": { "have": 12, "want": 35 }, - "label": [ - "Koch International Poland", - "Koch International Poland", - "Koch International Poland" - ], - "catno": "33972-6", - "year": "1997", - "genre": [ "Electronic" ], - "resource_url": "https://api.discogs.com/releases/1504243", - "type": "release", - "id": 1504243 - }, - { - "style": [ "Breakbeat", "Hardcore", "Techno", "Jungle", "Acid" ], - "thumb": "https://api.discogs.com/image/R-90-1424405-1383240638-9249.jpeg", - "format": [ "Cassette", "Compilation", "Box Set", "Limited Edition" ], - "country": "Poland", - "title": "Prodigy* - 3x Prodigy 2", - "uri": "/Prodigy-3x-Prodigy-2/release/1424405", - "community": { "have": 5, "want": 29 }, - "label": [ - "Koch International Poland", - "Koch International Poland", - "Koch International Poland" - ], - "catno": "33972-7", - "year": "1997", - "genre": [ "Electronic" ], - "resource_url": "https://api.discogs.com/releases/1424405", - "type": "release", - "id": 1424405 - }, - { - "style": [ "Breakbeat", "Acid", "Big Beat" ], - "thumb": "https://api.discogs.com/image/R-90-1424455-1383244782-4115.jpeg", - "format": [ "Cassette", "Compilation", "Box Set", "Limited Edition" ], - "country": "Poland", - "title": "Prodigy* - 3x Prodigy 3", - "uri": "/Prodigy-3x-Prodigy-3/release/1424455", - "community": { "have": 4, "want": 30 }, - "label": [ - "Koch International Poland", - "Koch International Poland", - "Koch International Poland" - ], - "catno": "33965-7", - "year": "1997", - "genre": [ "Electronic" ], - "resource_url": "https://api.discogs.com/releases/1424455", - "type": "release", - "id": 1424455 - }, - { - "style": [ "Breakbeat", "Electro", "Big Beat", "Jungle", "Happy Hardcore" ], - "thumb": "https://api.discogs.com/image/R-90-1504242-1383243041-5579.jpeg", - "format": [ "CD", "Maxi-Single", "Box Set", "Limited Edition", "Compilation" ], - "country": "Poland", - "title": "Prodigy* - 3x Prodigy 1", - "uri": "/Prodigy-3x-Prodigy-1/release/1504242", - "community": { "have": 9, "want": 36 }, - "label": [ - "Koch International Poland", - "Koch International Poland", - "Koch International Poland" - ], - "catno": "33973-6", - "year": "1997", - "genre": [ "Electronic" ], - "resource_url": "https://api.discogs.com/releases/1504242", - "type": "release", - "id": 1504242 - }, - { - "style": [ "Breakbeat", "Electro", "Big Beat", "Jungle", "Happy Hardcore" ], - "thumb": "https://api.discogs.com/image/R-90-1424359-1383237211-4608.jpeg", - "format": [ "Cassette", "Compilation", "Box Set", "Limited Edition" ], - "country": "Poland", - "title": "Prodigy* - 3x Prodigy 1", - "uri": "/Prodigy-3x-Prodigy-1/release/1424359", - "community": { "have": 5, "want": 28 }, - "label": [ - "Koch International Poland", - "Koch International Poland", - "Koch International Poland" - ], - "catno": "33973-7", - "year": "1997", - "genre": [ "Electronic" ], - "resource_url": "https://api.discogs.com/releases/1424359", - "type": "release", - "id": 1424359 - }, - { - "style": [ "Breakbeat", "Acid", "Big Beat" ], - "thumb": "https://api.discogs.com/image/R-90-1504244-1323283992.jpeg", - "format": [ "CD", "Maxi-Single", "Box Set", "Limited Edition", "Compilation" ], - "country": "Poland", - "title": "Prodigy* - 3x Prodigy 3", - "uri": "/Prodigy-3x-Prodigy-3/release/1504244", - "community": { "have": 13, "want": 33 }, - "label": [ - "Koch International Poland", - "Koch International Poland", - "Koch International Poland" - ], - "catno": "33965-6", - "year": "1997", - "genre": [ "Electronic" ], - "resource_url": "https://api.discogs.com/releases/1504244", - "type": "release", - "id": 1504244 - }, - { - "style": [ "Brass Band", "Jazz-Funk" ], - "thumb": "https://api.discogs.com/image/R-90-3654909-1339070773-5688.jpeg", - "format": [ "Vinyl", "7\"" ], - "country": "UK", - "title": "Hackney Colliery Band - Prodigy Medley / Owl Sanctuary", - "uri": "/Hackney-Colliery-Band-Prodigy-Medley-Owl-Sanctuary/release/3654909", - "community": { "have": 37, "want": 34 }, - "label": [ "Wah Wah 45s" ], - "catno": "WAH7039", - "year": "2012", - "genre": [ "Brass & Military", "Funk / Soul", "Jazz" ], - "resource_url": "https://api.discogs.com/releases/3654909", - "type": "release", - "id": 3654909 - }, - { - "thumb": "https://api.discogs.com/image/R-90-3706713-1341145809-7181.jpeg", - "format": [ "CD", "Album" ], - "country": "US", - "title": "Gajah (Acid Reign)* - Poverty's Prodigy", - "uri": "/Gajah-Acid-Reign-Povertys-Prodigy/release/3706713", - "community": { "have": 1, "want": 2 }, - "label": [ "Acid Lab Records" ], - "catno": "ALR-001", - "year": "2012", - "genre": [ "Hip Hop" ], - "resource_url": "https://api.discogs.com/releases/3706713", - "type": "release", - "id": 3706713 - }, - { - "style": [ "Breakbeat", "Techno", "Electro", "Big Beat" ], - "thumb": "https://api.discogs.com/image/R-90-3254091-1345224099-2736.jpeg", - "format": [ "DVD", "DVD-Video", "PAL", "Unofficial Release" ], - "country": "Russia", - "barcode": [ "4602536584243", "F-2120 90585634" ], - "uri": "/Prodigy-\u0417\u0432\u0451\u0437\u0434\u044b-\u0410\u0432\u0442\u043e\u0440\u0430\u0434\u0438\u043e-Prodigy/release/3254091", - "community": { "have": 1, "want": 1 }, - "label": [ "\u0410\u043b\u044c\u044f\u043d\u0441 Imaging" ], - "catno": "none", - "year": "2007", - "genre": [ "Electronic" ], - "title": "Prodigy* - \u0417\u0432\u0451\u0437\u0434\u044b \u0410\u0432\u0442\u043e\u0440\u0430\u0434\u0438\u043e - Prodigy", - "resource_url": "https://api.discogs.com/releases/3254091", - "type": "release", - "id": 3254091 - }, - { - "style": [ "Breakbeat", "Techno", "Big Beat" ], - "thumb": "https://api.discogs.com/images/default-release.png", - "format": [ "CD", "Compilation", "Unofficial Release" ], - "country": "Canada", - "title": "Prodigy, The - The Best Prodigy", - "uri": "/Prodigy-The-Best-Prodigy/release/4614899", - "community": { "have": 1, "want": 0 }, - "label": [ "XL Recordings" ], - "catno": "none", - "year": "1996", - "genre": [ "Electronic" ], - "resource_url": "https://api.discogs.com/releases/4614899", - "type": "release", - "id": 4614899 - }, - { - "style": [ "Ambient", "Experimental", "Modern Classical" ], - "thumb": "https://api.discogs.com/image/R-90-5108513-1384823867-5756.jpeg", - "format": [ "File", "MP3", "Compilation", "Mixed" ], - "country": "Mexico", - "title": "Leandro Fresco - Prodigy Msn M\u00e9xico Podcast", - "uri": "/Leandro-Fresco-Prodigy-Msn-M\u00e9xico-Podcast/release/5108513", - "community": { "have": 0, "want": 2 }, - "label": [ "Prodigy Msn" ], - "catno": "099", - "year": "2013", - "genre": [ "Electronic" ], - "resource_url": "https://api.discogs.com/releases/5108513", - "type": "release", - "id": 5108513 - }, - { - "style": [ "Hip Hop" ], - "thumb": "https://api.discogs.com/image/R-90-4087807-1354820150-9027.jpeg", - "format": [ "CDr", "Single", "Promo" ], - "country": "UK", - "title": "Jay-Z - 99 Problems - Prodigy Remix", - "uri": "/Jay-Z-99-Problems-The-Prodigy-Remix/release/4087807", - "community": { "have": 4, "want": 10 }, - "label": [ "Mercury" ], - "catno": "none", - "year": "2010", - "genre": [ "Electronic" ], - "resource_url": "https://api.discogs.com/releases/4087807", - "type": "release", - "id": 4087807 - }, - { - "style": [ "Chiptune", "Breakcore" ], - "thumb": "https://api.discogs.com/image/R-90-2585069-1291741548.jpeg", - "format": [ "File", "MP3", "Compilation" ], - "country": "Russia", - "title": "Various - The Prodigy - Emulator | Punks!!", - "uri": "/Various-The-Prodigy-Emulator-Punks/release/2585069", - "community": { "have": 1, "want": 0 }, - "label": [ "The Prodigy Guide" ], - "catno": "none", - "year": "2009", - "genre": [ "Electronic" ], - "resource_url": "https://api.discogs.com/releases/2585069", - "type": "release", - "id": 2585069 - }, - { - "style": [ "Alternative Rock", "House", "Techno", "Big Beat", "Experimental" ], - "thumb": "https://api.discogs.com/images/default-release.png", - "format": [ "CD", "Compilation", "Partially Mixed", "Unofficial Release" ], - "country": "Russia", - "barcode": [ "7 56254 67765", "75625467765", "ITL 021234" ], - "uri": "/Various-Tribute-To-Prodigy/release/3403189", - "community": { "have": 1, "want": 1 }, - "label": [ "Halahup" ], - "catno": "ITL 021234", - "genre": [ "Electronic", "Rock" ], - "title": "Various - Tribute To Prodigy", - "resource_url": "https://api.discogs.com/releases/3403189", - "type": "release", - "id": 3403189 - }, - { - "style": [ "Death Metal" ], - "thumb": "https://api.discogs.com/image/R-90-4183124-1388628699-8610.jpeg", - "format": [ "CDr", "Promo", "Mini-Album" ], - "country": "Sweden", - "title": "Death Maze - Prodigy Of Death", - "uri": "/Death-Maze-Prodigy-Of-Death/release/4183124", - "community": { "have": 1, "want": 0 }, - "label": [ "Dimrass Studios, Ume\u00e5", "Not On Label" ], - "catno": "", - "year": "2008", - "genre": [ "Rock" ], - "resource_url": "https://api.discogs.com/releases/4183124", - "type": "release", - "id": 4183124 - }, - { - "style": [ "Spoken Word" ], - "thumb": "https://api.discogs.com/image/R-90-483712-1177177737.jpeg", - "format": [ "CD", "Unofficial Release" ], - "country": "UK", - "barcode": [ "823564019420", "S10513 ABCD 194", "ISBN 1842402951" ], - "uri": "/Prodigy-Maximum-Prodigy-The-Unauthorised-Biography-Of-The-Prodigy/release/483712", - "community": { "have": 13, "want": 2 }, - "label": [ - "Chrome Dreams", - "Chrome Dreams", - "Maximum", - "Chrome Dreams", - "Chrome Dreams" - ], - "catno": "ABCD194", - "year": "2004", - "genre": [ "Non-Music" ], - "title": "Prodigy, The - Maximum Prodigy (The Unauthorised Biography Of The Prodigy)", - "resource_url": "https://api.discogs.com/releases/483712", - "type": "release", - "id": 483712 - }, - { - "style": [ "House" ], - "thumb": "https://api.discogs.com/image/R-90-9907-1398962319-4581.jpeg", - "format": [ "Vinyl", "12\"", "33 \u2153 RPM", "EP" ], - "country": "US", - "barcode": [ "TM\u2022002 A [unreadable]", "TM\u2022002 B [unreadable]", "BMI" ], - "uri": "/Brett-Dancer-Rhythm-Prodigy-EP/release/9907", - "community": { "have": 60, "want": 278 }, - "label": [ "Track Mode", "Third Mode Music", "Third Mode Music" ], - "catno": "TM-002", - "year": "1995", - "genre": [ "Electronic" ], - "title": "Brett Dancer - Rhythm Prodigy E.P.", - "resource_url": "https://api.discogs.com/releases/9907", - "type": "release", - "id": 9907 - }, - { - "style": [ "Goth Rock", "Ethereal" ], - "thumb": "https://api.discogs.com/image/R-90-725225-1309562875.jpeg", - "format": [ "CD", "Limited Edition" ], - "country": "Belgium", - "title": "Swan Death - Darkness, Prodigy And Virtuosity", - "uri": "/Swan-Death-Darkness-Prodigy-And-Virtuosity/release/725225", - "community": { "have": 11, "want": 8 }, - "label": [ "Mbs Records" ], - "catno": "MBS 94001", - "year": "1993", - "genre": [ "Electronic", "Rock" ], - "resource_url": "https://api.discogs.com/releases/725225", - "type": "release", - "id": 725225 - }, - { - "style": [ "Breakbeat", "Hardcore" ], - "thumb": "https://api.discogs.com/image/R-90-338303-1098383827.jpg", - "format": [ "Vinyl", "12\"", "Unofficial Release", "EP", "33 \u2153 RPM" ], - "country": "", - "title": "Prodigy, The - The Prodigy E.P.", - "uri": "/Prodigy-The-Prodigy-EP/release/338303", - "community": { "have": 80, "want": 55 }, - "label": [ "Not On Label (The Prodigy)" ], - "catno": "PEP 001", - "year": "2004", - "genre": [ "Electronic" ], - "resource_url": "https://api.discogs.com/releases/338303", - "type": "release", - "id": 338303 - }, - { - "style": [ "Gangsta" ], - "thumb": "https://api.discogs.com/image/R-90-793558-1160588365.jpeg", - "format": [ "Vinyl", "12\"" ], - "country": "US", - "title": "Duce Duce - Prodigy Of A Nigga / Twisting Dank", - "uri": "/Duce-Duce-Prodigy-Of-A-Nigga-Twisting-Dank/release/793558", - "community": { "have": 9, "want": 17 }, - "label": [ "Torcha Chamba Productions" ], - "catno": "TCR 101", - "year": "1995", - "genre": [ "Hip Hop" ], - "resource_url": "https://api.discogs.com/releases/793558", - "type": "release", - "id": 793558 - }, - { - "style": [ "Breakbeat", "Big Beat" ], - "thumb": "https://api.discogs.com/image/R-90-3460439-1331283929.jpeg", - "format": [ "File", "MP3" ], - "country": "US", - "title": "Plan B (4) - Ill Manors (The Prodigy Remix)", - "uri": "/Plan-B-Ill-Manors-The-Prodigy-Remix/release/3460439", - "community": { "have": 10, "want": 2 }, - "label": [ "RCRD LBL" ], - "catno": "none", - "year": "2012", - "genre": [ "Electronic", "Hip Hop" ], - "resource_url": "https://api.discogs.com/releases/3460439", - "type": "release", - "id": 3460439 - }, - { - "style": [ "Breakbeat" ], - "thumb": "https://api.discogs.com/image/R-90-3759856-1343257188-1103.jpeg", - "format": [ "File", "WAV" ], - "country": "US", - "title": "Foo Fighters - White Limo (The Prodigy Remix)", - "uri": "/Prodigy-White-Limo-Remix/release/3759856", - "community": { "have": 11, "want": 6 }, - "label": [ "Red Ant" ], - "catno": "none", - "year": "2011", - "genre": [ "Electronic", "Rock" ], - "resource_url": "https://api.discogs.com/releases/3759856", - "type": "release", - "id": 3759856 - }, - { - "style": [ "Breakbeat", "Musical", "Big Beat" ], - "thumb": "https://api.discogs.com/images/default-release.png", - "format": [ "VHS", "Compilation", "PAL", "Unofficial Release" ], - "country": "Russia", - "title": "Prodigy* - Prodigy Live At Brixton Academy", - "uri": "/Prodigy-Prodigy-Live-At-Brixton-Academy/release/3343249", - "community": { "have": 1, "want": 3 }, - "label": [ "\u0412\u0438\u0434\u0435\u043e \u0421\u0430\u043b\u044e\u0442" ], - "catno": "none", - "year": "1997", - "genre": [ "Electronic", "Stage & Screen" ], - "resource_url": "https://api.discogs.com/releases/3343249", - "type": "release", - "id": 3343249 - }, - { - "style": [ "Breakbeat", "Dubstep" ], - "thumb": "https://api.discogs.com/image/R-90-2727688-1298315899.jpeg", - "format": [ "File", "MP3" ], - "country": "UK", - "title": "South Central (3) - The Day I Die (Prodigy Rework)", - "uri": "/South-Central-The-Day-I-Die-Prodigy-Rework/release/2727688", - "community": { "have": 11, "want": 1 }, - "label": [ "Not On Label (South Central (3) Self-released)" ], - "catno": "none", - "year": "2011", - "genre": [ "Electronic" ], - "resource_url": "https://api.discogs.com/releases/2727688", - "type": "release", - "id": 2727688 - }, - { - "thumb": "https://api.discogs.com/image/R-90-1783903-1243088134.gif", - "format": [ "CDr", "Mixed" ], - "country": "US", - "title": "Littles - What's Beef? Fuck Prodigy!!!", - "uri": "/Littles-Whats-Beef-Fuck-Prodigy/release/1783903", - "community": { "have": 12, "want": 4 }, - "label": [ "Best Of The Block Entertainment" ], - "catno": "none", - "year": "2004", - "genre": [ "Hip Hop" ], - "resource_url": "https://api.discogs.com/releases/1783903", - "type": "release", - "id": 1783903 - } - ] - } -}