diff --git a/.env.example b/.env.example
new file mode 100644
index 0000000..d2afe53
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,2 @@
+# get your apiKey in https://retroachievements.org/settings
+RETROACHIEVEMENTS_API_KEY=
\ No newline at end of file
diff --git a/.gitignore b/.gitignore
index a7f372d..c240c39 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,11 +1,12 @@
.idea
.phpunit.cache
+.phpunit.result.cache
build
composer.lock
coverage
docs
-phpunit.xml
-phpstan.neon
testbench.yaml
vendor
node_modules
+.env
+public/index.php
diff --git a/README.md b/README.md
index e0b7f62..c20abd2 100644
--- a/README.md
+++ b/README.md
@@ -6,6 +6,11 @@
[](https://github.com/retroachievements/api/actions?query=workflow%3A"Fix+PHP+code+style+issues"+branch%3Amain)
[](https://packagist.org/packages/retroachievements/api)
+## Requirements
+
+- PHP 8.2+
+- Guzzle 7.0+
+
## Installation
Install the package via composer:
@@ -14,9 +19,135 @@ Install the package via composer:
composer require retroachievements/api
```
+## Configuration
+
+Add your API key to your `.env` file:
+
+```
+RETROACHIEVEMENTS_API_KEY=your-api-key-here
+```
+
+You can get your API key at [retroachievements.org/settings](https://retroachievements.org/settings).
+
## Usage
-WIP
+### Standalone
+
+in your php file add:
+```php
+setApiKey('your-api-key-here');
+
+$profile = $api->getUserProfile('MaxMilyin');
+$achievements = $api->getUserRecentAchievements('MaxMilyin', 60);
+
+echo $profile->User;
+echo $profile->TotalPoints;
+
+foreach ($achievements as $achievement) {
+ echo $achievement->Title;
+ echo $achievement->GameTitle;
+}
+```
+
+### Laravel
+
+Publish the config file:
+
+```shell
+php artisan vendor:publish --provider="RetroAchievements\Providers\RetroAchievementsProvider"
+```
+
+### Manual Registration (if auto-discovery fails)
+
+If the provider is not discovered automatically, add it manually to `bootstrap/providers.php` (Laravel 11+):
+
+```php
+return [
+ RetroAchievements\Providers\RetroAchievementsProvider::class,
+];
+```
+
+Use the Facade:
+
+```php
+use RetroAchievements\Facades\RetroAchievements;
+
+$profile = RetroAchievements::getUserProfile('MaxMilyin');
+$achievements = RetroAchievements::getUserRecentAchievements('MaxMilyin', 60);
+```
+
+Switching API keys at runtime:
+
+```php
+foreach ($users as $user) {
+ RetroAchievements::setApiKey($user->ra_api_key);
+
+ $profile = RetroAchievements::getUserProfile($user->ra_username);
+}
+```
+
+## Error Handling
+
+```php
+use RetroAchievements\Exceptions\ApiException;
+use RetroAchievements\Exceptions\InvalidApiKeyException;
+use RetroAchievements\Exceptions\InvalidDateException;
+
+try {
+ $profile = RetroAchievements::getUserProfile('NickGoat1990');
+
+} catch (InvalidApiKeyException $e) {
+ // Invalid or missing API key
+} catch (InvalidDateException $e) {
+ // Invalid date format or range
+} catch (ApiException $e) {
+ // General API error
+}
+```
+
+## Available Methods
+
+### User
+| Method | Description |
+|--------|-------------|
+| `getUserProfile(string $username)` | Get user profile |
+| `getUserRecentAchievements(string $username, ?int $minutes)` | Get recent achievements |
+| `getAchievementsEarnedBetween(string $username, string $from, string $to)` | Get achievements earned in a date range |
+| `getAchievementsEarnedOnDay(string $username, string $date)` | Get achievements earned on a specific day |
+| `getUserPoints(string $username)` | Get user points |
+| `getUserRankAndScore(string $username)` | Get user rank and score |
+| `getUserSummary(string $username)` | Get user summary |
+| `getUserCompletionProgress(string $username)` | Get user completion progress |
+| `getUserAwards(string $username)` | Get user awards |
+| `getUserRecentlyPlayedGames(string $username, ?int $limit)` | Get recently played games |
+| `getUserWantToPlayList(string $username, ?int $limit)` | Get want to play list |
+| `getUserCompletedGames(string $username)` | Get completed games |
+| `getUserProgress(string $username, array $gameIds)` | Get user progress for specific games |
+| `getUserClaims(string $username)` | Get user claims |
+| `getUserGameRankAndScore(string $username, int $gameId)` | Get user rank and score for a specific game |
+
+### Games & Consoles
+| Method | Description |
+|--------|-------------|
+| `getGameInfo(int $gameId)` | Get game information |
+| `getGameInfoExtended(int $gameId)` | Get extended game information |
+| `getGameInfoAndUserProgress(string $username, int $gameId)` | Get game info with user progress |
+| `getGameList(int $consoleId)` | Get game list for a console |
+| `getConsoleIDs()` | Get all console IDs |
+
+### Feed & Ranking
+| Method | Description |
+|--------|-------------|
+| `getTopTenUsers()` | Get top ten users |
+| `getFeedFor(string $username, int $count, int $offset)` | Get feed for a user |
+| `getAchievementOfTheWeek()` | Get achievement of the week |
## Testing
@@ -24,6 +155,14 @@ WIP
composer test
```
+## Quality
+
+```shell
+composer check # runs pint + phpstan + phpunit
+composer fix # auto-fix code style
+composer stan # phpstan only
+```
+
## Contributing
See [Contribution Guidelines](CONTRIBUTING.md) and [Code of Conduct](CODE_OF_CONDUCT.md).
@@ -38,4 +177,4 @@ Please review [our security policy](../../security/policy).
## License
-MIT License (MIT). See [License File](LICENSE.md).
+MIT License (MIT). See [License File](LICENSE.md).
\ No newline at end of file
diff --git a/composer.json b/composer.json
index 9f4a16f..f60d4a8 100644
--- a/composer.json
+++ b/composer.json
@@ -11,9 +11,9 @@
"homepage": "https://github.com/retroachievements/api-php",
"license": "MIT",
"require": {
- "php": "^8.2"
+ "php": "^8.2",
+ "guzzlehttp/guzzle": "^7.12"
},
- "require-dev": {},
"minimum-stability": "dev",
"prefer-stable": true,
"config": {
@@ -21,7 +21,7 @@
},
"autoload": {
"psr-4": {
- "RetroAchievements\\Api\\": "src"
+ "RetroAchievements\\": "src"
}
},
"autoload-dev": {
@@ -29,5 +29,28 @@
"Tests\\": "tests"
}
},
- "scripts": {}
+ "extra": {
+ "laravel": {
+ "providers": [
+ "RetroAchievements\\Providers\\RetroAchievementsProvider"
+ ],
+ "aliases": {
+ "RetroAchievements": "RetroAchievements\\Facades\\RetroAchievements"
+ }
+ }
+ },
+ "scripts": {
+ "test": "vendor/bin/phpunit --testdox",
+ "lint": "vendor/bin/pint --test",
+ "fix": "vendor/bin/pint",
+ "stan": "vendor/bin/phpstan analyse",
+ "check": ["@lint", "@stan", "@test"]
+ },
+ "require-dev": {
+ "laravel/framework": "^13.17",
+ "laravel/pint": "^1.29",
+ "phpstan/phpstan": "^2.2",
+ "phpunit/phpunit": "^13.2",
+ "vlucas/phpdotenv": "^5.6"
+ }
}
diff --git a/phpstan.neon b/phpstan.neon
new file mode 100644
index 0000000..796d6d9
--- /dev/null
+++ b/phpstan.neon
@@ -0,0 +1,5 @@
+parameters:
+ level: 8
+ paths:
+ - src
+ ignoreErrors: []
\ No newline at end of file
diff --git a/phpunit.xml b/phpunit.xml
new file mode 100644
index 0000000..9eeb26d
--- /dev/null
+++ b/phpunit.xml
@@ -0,0 +1,11 @@
+
+
+
+
+ ./tests/Unit
+
+
+
\ No newline at end of file
diff --git a/public/index.example.php b/public/index.example.php
new file mode 100644
index 0000000..edf958a
--- /dev/null
+++ b/public/index.example.php
@@ -0,0 +1,61 @@
+setApiKey('your-api-key-here');
+
+$profile = $api->getUserProfile('MaxMilyin');
+$achievementsEarnedBetween = $api->getAchievementsEarnedBetween('MaxMilyin', '2024-01-01', '2024-01-30');
+$userSummary = $api->getUserSummary('MaxMilyin', 1, 3);
+$consoleIds = $api->getConsoleIDs();
+
+var_dump($profile);
+var_dump($achievementsEarnedBetween);
+
+// ---------------------------------------------------------------------------
+// Laravel bootstrap (only needed outside a Laravel project)
+// ---------------------------------------------------------------------------
+
+use Illuminate\Config\Repository;
+use Illuminate\Foundation\Application;
+use Illuminate\Support\Facades\Facade;
+use RetroAchievements\Facades\RetroAchievements;
+use RetroAchievements\Providers\RetroAchievementsProvider;
+
+$dotenv = Dotenv\Dotenv::createImmutable(__DIR__.'/../');
+$dotenv->load();
+
+$app = new Application(__DIR__.'/../');
+
+$app->singleton('config', function () {
+ return new Repository([
+ 'retroachievements' => require __DIR__.'/../src/config/retroachievements.php',
+ ]);
+});
+
+Facade::setFacadeApplication($app);
+
+$provider = new RetroAchievementsProvider($app);
+$provider->register();
+$provider->boot();
+
+// ---------------------------------------------------------------------------
+// Laravel usage
+// In a real Laravel project, everything above is handled automatically.
+// Just use the Facade directly:
+// ---------------------------------------------------------------------------
+
+RetroAchievements::setApiKey('your-api-key-here'); // optional if set in .env
+
+$profile = RetroAchievements::getUserProfile('MaxMilyin');
+$achievements = RetroAchievements::getUserRecentAchievements('MaxMilyin', 525600);
+
+var_dump($profile);
+var_dump($achievements);
diff --git a/src/Exceptions/ApiException.php b/src/Exceptions/ApiException.php
new file mode 100644
index 0000000..7bd055e
--- /dev/null
+++ b/src/Exceptions/ApiException.php
@@ -0,0 +1,7 @@
+ $gameIds)
+ * @method static object getUserRecentlyPlayedGames(string $username, ?int $limit = 5)
+ * @method static object getUserSummary(string $username, ?int $totalGames = 0, ?int $totalAchievements = 3)
+ * @method static object getUserCompletedGames(string $username)
+ * @method static object getUserWantToPlayList(string $username, ?int $limit = 10)
+ * @method static object getUserRankAndScore(string $username)
+ *
+ * Games & Consoles
+ * @method static object getGameInfo(int $gameId)
+ * @method static object getGameInfoExtended(int $gameId)
+ * @method static object getConsoleIDs()
+ * @method static object getGameList(int $consoleId)
+ *
+ * Feed
+ * @method static object getFeedFor(string $username, int $count, int $offset = 0)
+ *
+ * Ranking
+ * @method static object getTopTenUsers()
+ *
+ * Events
+ * @method static object getAchievementOfTheWeek()
+ *
+ * @see RetroAchievementsApiClient
+ */
+class RetroAchievements extends Facade
+{
+ #[Override]
+ protected static function getFacadeAccessor(): string
+ {
+ return RetroAchievementsApiClient::class;
+ }
+}
diff --git a/src/Providers/RetroAchievementsProvider.php b/src/Providers/RetroAchievementsProvider.php
new file mode 100644
index 0000000..204b954
--- /dev/null
+++ b/src/Providers/RetroAchievementsProvider.php
@@ -0,0 +1,33 @@
+publishes([
+ __DIR__.'/../config/retroachievements.php' => config_path('retroachievements.php'),
+ ], 'config');
+ }
+
+ public function register(): void
+ {
+ $this->mergeConfigFrom(
+ __DIR__.'/../config/retroachievements.php',
+ 'retroachievements'
+ );
+
+ $this->app->bind(RetroAchievementsApiClient::class, function ($app) {
+ $service = new RetroAchievementsApiClient;
+ $service->setApiKey($app['config']['retroachievements.api_key'] ?? '');
+
+ return $service;
+ });
+ }
+}
diff --git a/src/RetroAchievementsApiClient.php b/src/RetroAchievementsApiClient.php
deleted file mode 100644
index 4903ac2..0000000
--- a/src/RetroAchievementsApiClient.php
+++ /dev/null
@@ -1,185 +0,0 @@
-ra_user = $user;
- $this->ra_api_key = $api_key;
- }
-
- /**
- * @return string
- */
- private function AuthQS()
- {
- return "?z=" . $this->ra_user . "&y=" . $this->ra_api_key;
- }
-
- /**
- * @param string $target
- * @param string $params
- * @return bool|null|string
- */
- private function GetRAURL($target, $params = "")
- {
- try {
- return file_get_contents(self::API_URL . $target . self::AuthQS() . "&$params");
- } catch (Exception $e) {
- }
- return null;
- }
-
- /**
- * @return mixed
- */
- public function GetTopTenUsers()
- {
- return json_decode(self::GetRAURL('API_GetTopTenUsers.php'));
- }
-
- /**
- * @param int $gameID
- * @return mixed
- */
- public function GetGameInfo($gameID)
- {
- return json_decode(self::GetRAURL("API_GetGame.php", "i=$gameID"));
- }
-
- /**
- * @param int $gameID
- * @return mixed
- */
- public function GetGameInfoExtended($gameID)
- {
- return json_decode(self::GetRAURL("API_GetGameExtended.php", "i=$gameID"));
- }
-
- /**
- * @return mixed
- */
- public function GetConsoleIDs()
- {
- return json_decode(self::GetRAURL('API_GetConsoleIDs.php'));
- }
-
- /**
- * @param int $consoleID
- * @return mixed
- */
- public function GetGameList($consoleID)
- {
- return json_decode(self::GetRAURL("API_GetGameList.php", "i=$consoleID"));
- }
-
- /**
- * @param string $user
- * @param int $count
- * @param int $offset
- * @return mixed
- */
- public function GetFeedFor($user, $count, $offset = 0)
- {
- return json_decode(self::GetRAURL("API_GetFeed.php", "u=$user&c=$count&o=$offset"));
- }
-
- /**
- * @param string $user
- * @return mixed
- */
- public function GetUserRankAndScore($user)
- {
- return json_decode(self::GetRAURL("API_GetUserRankAndScore.php", "u=$user"));
- }
-
- /**
- * @param string $user
- * @param string $gameIDCSV
- * @return mixed
- */
- public function GetUserProgress($user, $gameIDCSV)
- {
- $gameIDCSV = preg_replace('/\s+/', '', $gameIDCSV); // Remove all whitespace
- return json_decode(self::GetRAURL("API_GetUserProgress.php", "u=$user&i=$gameIDCSV"));
- }
-
- /**
- * @param string $user
- * @param int $count
- * @param int $offset
- * @return mixed
- */
- public function GetUserRecentlyPlayedGames($user, $count, $offset = 0)
- {
- return json_decode(self::GetRAURL("API_GetUserRecentlyPlayedGames.php", "u=$user&c=$count&o=$offset"));
- }
-
- /**
- * @param string $user
- * @param int $numRecentGames
- * @return mixed
- */
- public function GetUserSummary($user, $numRecentGames)
- {
- return json_decode(self::GetRAURL("API_GetUserSummary.php", "u=$user&g=$numRecentGames&a=5"));
- }
-
- /**
- * @param string $user
- * @param int $gameID
- * @return mixed
- */
- public function GetGameInfoAndUserProgress($user, $gameID)
- {
- return json_decode(self::GetRAURL("API_GetGameInfoAndUserProgress.php", "u=$user&g=$gameID"));
- }
-
- /**
- * @param string $user
- * @param int $dateInput
- * @return mixed
- */
- public function GetAchievementsEarnedOnDay($user, $dateInput)
- {
- return json_decode(self::GetRAURL("API_GetAchievementsEarnedOnDay.php", "u=$user&d=$dateInput"));
- }
-
- /**
- * @param string $user
- * @param int $dateStart
- * @param int $dateEnd
- * @return mixed
- */
- public function GetAchievementsEarnedBetween($user, $dateStart, $dateEnd)
- {
- $dateFrom = strtotime($dateStart);
- $dateTo = strtotime($dateEnd);
- return json_decode(self::GetRAURL("API_GetAchievementsEarnedBetween.php", "u=$user&f=$dateFrom&t=$dateTo"));
- }
-}
-
-;
diff --git a/src/Services/RetroAchievementsApiClient.php b/src/Services/RetroAchievementsApiClient.php
new file mode 100644
index 0000000..dc9f25f
--- /dev/null
+++ b/src/Services/RetroAchievementsApiClient.php
@@ -0,0 +1,206 @@
+baseUrl = 'https://retroachievements.org/API/';
+ }
+
+ public function getUserProfile(string $username): object
+ {
+ return $this->get('API_GetUserProfile.php', [
+ 'u' => $username,
+ ]);
+ }
+
+ public function getUserRecentAchievements(string $username, ?int $minutes = 60): object
+ {
+ return $this->get('API_GetUserRecentAchievements.php', [
+ 'u' => $username,
+ 'm' => $minutes,
+ ]);
+ }
+
+ public function getAchievementsEarnedBetween(string $username, string $from, string $to): object
+ {
+ $fromTs = strtotime($from);
+ $toTs = strtotime($to);
+
+ if ($fromTs === false || $toTs === false) {
+ throw new InvalidDateException(
+ "Invalid date: from={$from}, to={$to}"
+ );
+ }
+
+ if ($fromTs > $toTs) {
+ throw new InvalidDateException(
+ "Start date ({$from}) cannot be greater than end date ({$to})"
+ );
+ }
+
+ return $this->get('API_GetAchievementsEarnedBetween.php', [
+ 'u' => $username,
+ 'f' => $fromTs,
+ 't' => $toTs,
+ ]);
+ }
+
+ public function getAchievementsEarnedOnDay(string $username, string $date): object
+ {
+ return $this->get('API_GetAchievementsEarnedOnDay.php', [
+ 'u' => $username,
+ 'd' => $date,
+ ]);
+ }
+
+ public function getGameInfoAndUserProgress(string $username, int $gameId): object
+ {
+ return $this->get('API_GetGameInfoAndUserProgress.php', [
+ 'u' => $username,
+ 'g' => $gameId,
+ ]);
+ }
+
+ public function getUserCompletionProgress(string $username): object
+ {
+ return $this->get('API_GetUserCompletionProgress.php', [
+ 'u' => $username,
+ ]);
+ }
+
+ public function getUserAwards(string $username): object
+ {
+ return $this->get('API_GetUserAwards.php', [
+ 'u' => $username,
+ ]);
+ }
+
+ public function getUserClaims(string $username): object
+ {
+ return $this->get('API_GetUserClaims.php', [
+ 'u' => $username,
+ ]);
+ }
+
+ public function getUserGameRankAndScore(string $username, int $gameId): object
+ {
+ return $this->get('API_GetUserGameRankAndScore.php', [
+ 'u' => $username,
+ 'g' => $gameId,
+ ]);
+ }
+
+ public function getUserPoints(string $username): object
+ {
+ return $this->get('API_GetUserPoints.php', [
+ 'u' => $username,
+ ]);
+ }
+
+ /**
+ * @param array $gameIds
+ */
+ public function getUserProgress(string $username, array $gameIds): object
+ {
+ return $this->get('API_GetUserProgress.php', [
+ 'u' => $username,
+ 'i' => implode(',', $gameIds),
+ ]);
+ }
+
+ public function getUserRecentlyPlayedGames(string $username, ?int $limit = 5): object
+ {
+ return $this->get('API_GetUserRecentlyPlayedGames.php', [
+ 'u' => $username,
+ 'c' => $limit,
+ ]);
+ }
+
+ public function getUserSummary(string $username, ?int $totalGames = 0, ?int $totalAchievements = 3): object
+ {
+ return $this->get('API_GetUserSummary.php', [
+ 'u' => $username,
+ 'g' => $totalGames,
+ 'a' => $totalAchievements,
+ ]);
+ }
+
+ public function getTopTenUsers(): object
+ {
+ return $this->get('API_GetTopTenUsers.php');
+ }
+
+ public function getGameInfo(int $gameId): object
+ {
+ return $this->get('API_GetGame.php', [
+ 'i' => $gameId,
+ ]);
+ }
+
+ public function getGameInfoExtended(int $gameId): object
+ {
+ return $this->get('API_GetGameExtended.php', [
+ 'i' => $gameId,
+ ]);
+ }
+
+ public function getConsoleIDs(): object
+ {
+ return $this->get('API_GetConsoleIDs.php');
+ }
+
+ public function getGameList(int $consoleId): object
+ {
+ return $this->get('API_GetGameList.php', [
+ 'i' => $consoleId,
+ ]);
+ }
+
+ public function getFeedFor(string $username, int $count = 1, int $offset = 0): object
+ {
+ return $this->get('API_GetFeed.php', [
+ 'u' => $username,
+ 'c' => $count,
+ 'o' => $offset,
+ ]);
+ }
+
+ public function getUserRankAndScore(string $username): object
+ {
+ return $this->get('API_GetUserRankAndScore.php', [
+ 'u' => $username,
+ ]);
+ }
+
+ public function getUserCompletedGames(string $username): object
+ {
+ return $this->get('API_GetUserCompletedGames.php', [
+ 'u' => $username,
+ ]);
+ }
+
+ public function getUserWantToPlayList(string $username, ?int $limit = 10): object
+ {
+ return $this->get('API_GetUserWantToPlayList.php', [
+ 'u' => $username,
+ 'c' => $limit,
+ ]);
+ }
+
+ public function getAchievementOfTheWeek(): object
+ {
+ return $this->get('API_GetAchievementOfTheWeek.php');
+ }
+}
diff --git a/src/Traits/RetroAchievementsTrait.php b/src/Traits/RetroAchievementsTrait.php
new file mode 100644
index 0000000..55f150c
--- /dev/null
+++ b/src/Traits/RetroAchievementsTrait.php
@@ -0,0 +1,124 @@
+client = new Client([
+ 'base_uri' => $this->baseUrl,
+ 'handler' => $stack,
+ ]);
+ }
+
+ /**
+ * Resolve the Guzzle HTTP client instance.
+ */
+ protected function getClient(): Client
+ {
+ return $this->client ??= new Client([
+ 'base_uri' => $this->baseUrl,
+ 'http_errors' => false,
+ ]);
+ }
+
+ /**
+ * Perform a GET request to the given endpoint.
+ *
+ * @param array $params
+ */
+ public function get(string $endpoint, array $params = []): object
+ {
+ return $this->request('get', $endpoint, $params);
+ }
+
+ /**
+ * Perform an HTTP request and return the decoded response.
+ *
+ * @param array $params
+ *
+ * @throws InvalidApiKeyException
+ * @throws ApiException
+ */
+ protected function request(string $method, string $endpoint, array $params): object
+ {
+ try {
+ $response = $this->getClient()->request($method, $endpoint, [
+ 'query' => array_merge(['y' => $this->apiKey], $params),
+ ]);
+
+ $decoded = json_decode($response->getBody()->getContents(), false) ?? (object) [];
+
+ if (is_array($decoded)) {
+ return new ArrayObject($decoded);
+ }
+
+ return $decoded;
+
+ } catch (ClientException $e) {
+ $status = $e->getResponse()->getStatusCode();
+
+ if ($status === 401 || $status === 403) {
+ throw new InvalidApiKeyException(
+ 'Invalid or missing API key.',
+ $status,
+ $e
+ );
+ }
+
+ throw new ApiException(
+ "Client error: HTTP {$status} on endpoint {$endpoint}",
+ $status,
+ $e
+ );
+
+ } catch (ServerException $e) {
+ $status = $e->getResponse()->getStatusCode();
+
+ throw new ApiException(
+ "RetroAchievements server error: HTTP {$status} on endpoint {$endpoint}",
+ $status,
+ $e
+ );
+
+ } catch (ConnectException $e) {
+ throw new ApiException(
+ 'Could not connect to RetroAchievements.',
+ 0,
+ $e
+ );
+ }
+ }
+
+ /**
+ * Set the API key and reset the HTTP client.
+ */
+ public function setApiKey(string $apiKey): void
+ {
+ $this->apiKey = $apiKey;
+ $this->client = null;
+ }
+}
diff --git a/src/config/retroachievements.php b/src/config/retroachievements.php
new file mode 100644
index 0000000..a5a0bb5
--- /dev/null
+++ b/src/config/retroachievements.php
@@ -0,0 +1,5 @@
+ env('RETROACHIEVEMENTS_API_KEY'),
+];
diff --git a/tests/Fixtures/user_profile.json b/tests/Fixtures/user_profile.json
new file mode 100644
index 0000000..5fb0f5f
--- /dev/null
+++ b/tests/Fixtures/user_profile.json
@@ -0,0 +1,18 @@
+{
+ "User": "MaxMilyin",
+ "ULID": "00003EMFWR7XB8SDPEHB3K56ZQ",
+ "UserPic": "/UserPic/MaxMilyin.png",
+ "MemberSince": "2016-01-02 00:43:04",
+ "RichPresenceMsg": "Playing ~Hack~ 11th Annual Vanilla Level Design Contest, The",
+ "LastGameID": 19504,
+ "ContribCount": 0,
+ "ContribYield": 0,
+ "TotalPoints": 399597,
+ "TotalSoftcorePoints": 0,
+ "TotalTruePoints": 1599212,
+ "Permissions": 1,
+ "Untracked": 0,
+ "ID": 16446,
+ "UserWallActive": 1,
+ "Motto": "Join me on Twitch! GameSquadSquad for live RA"
+}
\ No newline at end of file
diff --git a/tests/Unit/RetroAchievementsTest.php b/tests/Unit/RetroAchievementsTest.php
new file mode 100644
index 0000000..9536aa5
--- /dev/null
+++ b/tests/Unit/RetroAchievementsTest.php
@@ -0,0 +1,147 @@
+raService = new RetroAchievementsApiClient;
+ }
+
+ private function makeService(array $responses): RetroAchievementsApiClient
+ {
+ $mock = new MockHandler($responses);
+ $stack = HandlerStack::create($mock);
+ $service = $this->raService;
+ $service->setHttpHandler($stack);
+
+ return $service;
+ }
+
+ private function fixture(string $name): string
+ {
+ return file_get_contents(__DIR__.'/../Fixtures/'.$name.'.json');
+ }
+
+ public function test_get_user_profile_returns_object(): void
+ {
+ $service = $this->makeService([
+ new Response(200, ['Content-Type' => 'application/json'], $this->fixture('user_profile')),
+ ]);
+
+ $result = $service->getUserProfile('MaxMilyin');
+
+ $this->assertIsObject($result);
+ $this->assertSame('MaxMilyin', $result->User);
+ $this->assertTrue(isset($result->TotalPoints));
+ }
+
+ public function test_get_user_profile_sends_correct_username(): void
+ {
+ $container = [];
+ $history = Middleware::history($container);
+ $mock = new MockHandler([
+ new Response(200, [], $this->fixture('user_profile')),
+ ]);
+ $stack = HandlerStack::create($mock);
+ $stack->push($history);
+
+ $this->raService->setHttpHandler($stack);
+ $this->raService->getUserProfile('MaxMilyin');
+
+ parse_str($container[0]['request']->getUri()->getQuery(), $query);
+
+ $this->assertSame('MaxMilyin', $query['u']);
+ }
+
+ public function test_achievements_earned_between_converts_dates_to_timestamps(): void
+ {
+ $container = [];
+ $history = Middleware::history($container);
+ $mock = new MockHandler([new Response(200, [], '{}')]);
+ $stack = HandlerStack::create($mock);
+ $stack->push($history);
+
+ $this->raService->setHttpHandler($stack);
+ $this->raService->getAchievementsEarnedBetween('User', '2024-01-01', '2024-01-31');
+
+ parse_str($container[0]['request']->getUri()->getQuery(), $query);
+
+ $this->assertSame((string) strtotime('2024-01-01'), $query['f']);
+ $this->assertSame((string) strtotime('2024-01-31'), $query['t']);
+ }
+
+ public function test_throws_invalid_date_exception_on_invalid_date(): void
+ {
+ $this->expectException(InvalidDateException::class);
+
+ $this->raService->getAchievementsEarnedBetween('User', 'invalid-date', 'another-invalid');
+ }
+
+ public function test_throws_invalid_date_exception_when_from_is_greater_than_to(): void
+ {
+ $this->expectException(InvalidDateException::class);
+
+ $this->raService->getAchievementsEarnedBetween('User', '2024-12-31', '2024-01-01');
+ }
+
+ public function test_returns_empty_object_on_empty_response(): void
+ {
+ $service = $this->makeService([
+ new Response(200, [], '{}'),
+ ]);
+
+ $result = $service->getUserProfile('nobody');
+
+ $this->assertIsObject($result);
+ }
+
+ public function test_throws_invalid_api_key_exception_on_403(): void
+ {
+ $this->expectException(InvalidApiKeyException::class);
+
+ $service = $this->makeService([
+ new Response(403, [], 'Forbidden'),
+ ]);
+
+ $service->getUserProfile('User');
+ }
+
+ public function test_throws_api_exception_on_server_error(): void
+ {
+ $this->expectException(ApiException::class);
+
+ $service = $this->makeService([
+ new Response(500, [], 'Internal Server Error'),
+ ]);
+
+ $service->getUserProfile('User');
+ }
+
+ public function test_throws_api_exception_on_client_error(): void
+ {
+ $this->expectException(ApiException::class);
+
+ $service = $this->makeService([
+ new Response(404, [], 'Not Found'),
+ ]);
+
+ $service->getUserProfile('User');
+ }
+}