mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-05 07:09:33 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb71b172f2 | ||
|
|
72f761b077
|
||
|
|
e8432df997
|
||
|
|
d99512a359
|
||
|
|
0ed6934da3 | ||
|
|
df7e506a5e
|
||
|
|
61385df92f | ||
|
|
57ce0b4a79
|
No files matched your search
@@ -0,0 +1,12 @@
|
||||
# Changelog
|
||||
|
||||
## 3.1.0 (2026-11-05)
|
||||
|
||||
- Add the scope `products:read` (`Scope::PRODUCTS_READ`) for read-only access to the product APIs
|
||||
of the Anikeen Console.
|
||||
- Add the reseller scopes `reseller-customers:read`, `reseller-customers`, `reseller-grants` and
|
||||
`reseller-orders` (`Scope::RESELLER_CUSTOMERS_READ`, `Scope::RESELLER_CUSTOMERS`,
|
||||
`Scope::RESELLER_GRANTS`, `Scope::RESELLER_ORDERS`) for the reseller API of the Anikeen Console.
|
||||
- Document the scope schema and the new scopes in the README.
|
||||
|
||||
Older releases: see the [GitHub releases](https://github.com/anikeen-com/id/releases) and tags.
|
||||
@@ -30,13 +30,13 @@ CI (`.github/workflows/tests.yml`) runs PHPUnit on PHP 8.3 and 8.4.
|
||||
attributes and break `save()`). Add a new cache property there when a concern caches.
|
||||
- `src/Id/Concerns/` – one trait per API area. Traits on `AnikeenId` act as the app/client;
|
||||
traits on `Billable` act for one user.
|
||||
- `src/Id/Resources/` – `BaseResource` (single object, throws `ResourceException`, or
|
||||
`ConfirmationRequiredException` for `confirmation_required`) and `BaseCollection` (lists,
|
||||
lazily built via `::builder()`).
|
||||
- `src/Id/Resources/` – `BaseResource` (single object) and `BaseCollection` (lists, built via
|
||||
`::builder()`). Failed requests throw `ApiException::fromResult()` (`CollectionException` for
|
||||
lists), no response `::unreachable()`, `confirmation_required` a `ConfirmationRequiredException`.
|
||||
All extend `ResourceException`.
|
||||
- `src/Id/Resources/App*` – "app" variants that use the app's own client-credentials token
|
||||
(`AnikeenId::asApp()`, `Support/AppRequest`) instead of the user's token, for trusted apps.
|
||||
They throw `Exceptions/ApiException` with a stable `error` code and must not send any request
|
||||
until a method is called.
|
||||
They must not send any request until a method is called.
|
||||
- `src/Id/Auth/`, `Providers/AnikeenIdServiceProvider.php`, `Http/Middleware/` – the `anikeen`
|
||||
guard driver and user provider, JWT validation (`Helpers/JwtParser.php` with the bundled
|
||||
`oauth-public*.key`), scope middleware.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
[](https://packagist.org/packages/anikeen/id)
|
||||
[](https://packagist.org/packages/anikeen/id)
|
||||
|
||||
PHP Anikeen ID API Client for Laravel 11+
|
||||
PHP Anikeen ID API Client for Laravel 12+
|
||||
|
||||
Sign in with Anikeen ID and let your users pay with their Anikeen ID account: one-off orders,
|
||||
subscriptions and physical goods, with invoices, VAT and payment handled by Anikeen ID.
|
||||
@@ -71,7 +71,7 @@ Add to `config/services.php` file:
|
||||
|
||||
### Event Listener
|
||||
|
||||
In Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` boot method:
|
||||
Since Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` boot method:
|
||||
|
||||
```php
|
||||
public function boot(): void
|
||||
@@ -160,6 +160,27 @@ if ($result->success()) {
|
||||
}
|
||||
```
|
||||
|
||||
### Scopes
|
||||
|
||||
Scopes follow one schema: `resource` grants full access, `resource:read` read-only access and
|
||||
`resource:client` access to what the app or token created itself. All of them are constants on
|
||||
`Anikeen\Id\Enums\Scope`, e.g. `Scope::ORDERS_READ`.
|
||||
|
||||
The Anikeen Console accepts personal access tokens and OAuth tokens (no client credentials) with
|
||||
these scopes for its product and reseller APIs:
|
||||
|
||||
| Scope | Constant | Grants |
|
||||
|-------|----------|--------|
|
||||
| `products:read` | `Scope::PRODUCTS_READ` | view the products of your teams |
|
||||
| `reseller-customers:read` | `Scope::RESELLER_CUSTOMERS_READ` | view a reseller team's customers and invitations |
|
||||
| `reseller-customers` | `Scope::RESELLER_CUSTOMERS` | manage customers: invite, resend, remove logins (includes reading) |
|
||||
| `reseller-grants` | `Scope::RESELLER_GRANTS` | grant customers access to products and revoke it |
|
||||
| `reseller-orders` | `Scope::RESELLER_ORDERS` | order products for customers, charged right away |
|
||||
|
||||
A full reseller scope includes reading. `user` and `*` do not open the reseller endpoints: the
|
||||
token has to carry the reseller scope itself. For the product APIs there is only `products:read`;
|
||||
changing products needs `user`.
|
||||
|
||||
### Change the default access token / refresh token field name
|
||||
|
||||
If your access / refresh token fields differ from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method:
|
||||
@@ -417,23 +438,37 @@ object through the API when the current state matters. The admin panel can send
|
||||
|
||||
### Errors
|
||||
|
||||
Calls with the user's token throw `Anikeen\Id\Exceptions\ResourceException` when Anikeen ID
|
||||
rejects them; `getCode()` is the HTTP status (e.g. `422` for invalid data, `404` for an unknown
|
||||
ID) and `getMessage()` the reason. App calls (`app*()`) throw its subclass `ApiException`, which
|
||||
adds a stable `error` code. Catching `ResourceException` covers both:
|
||||
Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as
|
||||
well as with the app's (`app*()`). It carries:
|
||||
|
||||
- `status`: the HTTP status (also `getCode()`), `0` when Anikeen ID could not be reached,
|
||||
- `error`: a stable error code when Anikeen ID sent one (e.g. `billing_address_missing`,
|
||||
`already_cancelled`), otherwise `null`,
|
||||
- `getMessage()`: Anikeen ID's reason,
|
||||
- `isNotFound()` (404), `isConflict()` (409) and `isTemporary()` (no response, 429 or 5xx: retry
|
||||
later).
|
||||
|
||||
```php
|
||||
use Anikeen\Id\Exceptions\ResourceException;
|
||||
use Anikeen\Id\Exceptions\ApiException;
|
||||
|
||||
try {
|
||||
$order = $user->orders()->create($attributes);
|
||||
} catch (ResourceException $e) {
|
||||
} catch (ApiException $e) {
|
||||
if ($e->isTemporary()) {
|
||||
return back()->with('error', 'Anikeen ID is not reachable right now, please try again.');
|
||||
}
|
||||
|
||||
report($e);
|
||||
|
||||
return back()->with('error', 'The order could not be created.');
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
```
|
||||
|
||||
Failed lists (`$user->invoices()`, …) throw `CollectionException`, a subclass of `ApiException`.
|
||||
`ConfirmationRequiredException` (a third-party app called `checkout()`, see above) stays its own
|
||||
type. All of them extend `ResourceException`, so existing `catch (ResourceException $e)` blocks
|
||||
keep working.
|
||||
|
||||
### Testing
|
||||
|
||||
Develop against staging (`ANIKEEN_ID_MODE=staging`) with an OAuth client registered there.
|
||||
@@ -700,7 +735,7 @@ $user = $result->shift(); // the object, or the first item of a list
|
||||
```
|
||||
|
||||
Resource methods (`$user->orders()`, `$anikeenId->sshKeysByUserId()`, …) return objects and throw
|
||||
`ResourceException` instead (see [Errors](#errors)).
|
||||
`ApiException` instead (see [Errors](#errors)).
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
+45
-10
@@ -4,7 +4,7 @@
|
||||
[](https://packagist.org/packages/anikeen/id)
|
||||
[](https://packagist.org/packages/anikeen/id)
|
||||
|
||||
PHP Anikeen ID API Client for Laravel 11+
|
||||
PHP Anikeen ID API Client for Laravel 12+
|
||||
|
||||
Sign in with Anikeen ID and let your users pay with their Anikeen ID account: one-off orders,
|
||||
subscriptions and physical goods, with invoices, VAT and payment handled by Anikeen ID.
|
||||
@@ -71,7 +71,7 @@ Add to `config/services.php` file:
|
||||
|
||||
### Event Listener
|
||||
|
||||
In Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` boot method:
|
||||
Since Laravel 11, the default EventServiceProvider provider was removed. Instead, add the listener using the listen method on the Event facade, in your `AppServiceProvider` boot method:
|
||||
|
||||
```php
|
||||
public function boot(): void
|
||||
@@ -160,6 +160,27 @@ if ($result->success()) {
|
||||
}
|
||||
```
|
||||
|
||||
### Scopes
|
||||
|
||||
Scopes follow one schema: `resource` grants full access, `resource:read` read-only access and
|
||||
`resource:client` access to what the app or token created itself. All of them are constants on
|
||||
`Anikeen\Id\Enums\Scope`, e.g. `Scope::ORDERS_READ`.
|
||||
|
||||
The Anikeen Console accepts personal access tokens and OAuth tokens (no client credentials) with
|
||||
these scopes for its product and reseller APIs:
|
||||
|
||||
| Scope | Constant | Grants |
|
||||
|-------|----------|--------|
|
||||
| `products:read` | `Scope::PRODUCTS_READ` | view the products of your teams |
|
||||
| `reseller-customers:read` | `Scope::RESELLER_CUSTOMERS_READ` | view a reseller team's customers and invitations |
|
||||
| `reseller-customers` | `Scope::RESELLER_CUSTOMERS` | manage customers: invite, resend, remove logins (includes reading) |
|
||||
| `reseller-grants` | `Scope::RESELLER_GRANTS` | grant customers access to products and revoke it |
|
||||
| `reseller-orders` | `Scope::RESELLER_ORDERS` | order products for customers, charged right away |
|
||||
|
||||
A full reseller scope includes reading. `user` and `*` do not open the reseller endpoints: the
|
||||
token has to carry the reseller scope itself. For the product APIs there is only `products:read`;
|
||||
changing products needs `user`.
|
||||
|
||||
### Change the default access token / refresh token field name
|
||||
|
||||
If your access / refresh token fields differ from the default `anikeen_id_access_token` / `anikeen_id_refresh_token`, you can specify the field name in the `AppServiceProvider` boot method:
|
||||
@@ -417,23 +438,37 @@ object through the API when the current state matters. The admin panel can send
|
||||
|
||||
### Errors
|
||||
|
||||
Calls with the user's token throw `Anikeen\Id\Exceptions\ResourceException` when Anikeen ID
|
||||
rejects them; `getCode()` is the HTTP status (e.g. `422` for invalid data, `404` for an unknown
|
||||
ID) and `getMessage()` the reason. App calls (`app*()`) throw its subclass `ApiException`, which
|
||||
adds a stable `error` code. Catching `ResourceException` covers both:
|
||||
Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as
|
||||
well as with the app's (`app*()`). It carries:
|
||||
|
||||
- `status`: the HTTP status (also `getCode()`), `0` when Anikeen ID could not be reached,
|
||||
- `error`: a stable error code when Anikeen ID sent one (e.g. `billing_address_missing`,
|
||||
`already_cancelled`), otherwise `null`,
|
||||
- `getMessage()`: Anikeen ID's reason,
|
||||
- `isNotFound()` (404), `isConflict()` (409) and `isTemporary()` (no response, 429 or 5xx: retry
|
||||
later).
|
||||
|
||||
```php
|
||||
use Anikeen\Id\Exceptions\ResourceException;
|
||||
use Anikeen\Id\Exceptions\ApiException;
|
||||
|
||||
try {
|
||||
$order = $user->orders()->create($attributes);
|
||||
} catch (ResourceException $e) {
|
||||
} catch (ApiException $e) {
|
||||
if ($e->isTemporary()) {
|
||||
return back()->with('error', 'Anikeen ID is not reachable right now, please try again.');
|
||||
}
|
||||
|
||||
report($e);
|
||||
|
||||
return back()->with('error', 'The order could not be created.');
|
||||
return back()->with('error', $e->getMessage());
|
||||
}
|
||||
```
|
||||
|
||||
Failed lists (`$user->invoices()`, …) throw `CollectionException`, a subclass of `ApiException`.
|
||||
`ConfirmationRequiredException` (a third-party app called `checkout()`, see above) stays its own
|
||||
type. All of them extend `ResourceException`, so existing `catch (ResourceException $e)` blocks
|
||||
keep working.
|
||||
|
||||
### Testing
|
||||
|
||||
Develop against staging (`ANIKEEN_ID_MODE=staging`) with an OAuth client registered there.
|
||||
@@ -700,7 +735,7 @@ $user = $result->shift(); // the object, or the first item of a list
|
||||
```
|
||||
|
||||
Resource methods (`$user->orders()`, `$anikeenId->sshKeysByUserId()`, …) return objects and throw
|
||||
`ResourceException` instead (see [Errors](#errors)).
|
||||
`ApiException` instead (see [Errors](#errors)).
|
||||
|
||||
## Examples
|
||||
|
||||
|
||||
+5
-5
@@ -15,15 +15,15 @@
|
||||
"require": {
|
||||
"php": "^8.2",
|
||||
"ext-json": "*",
|
||||
"illuminate/support": "^11.0|^12.0|^13.0",
|
||||
"illuminate/console": "^11.0|^12.0|^13.0",
|
||||
"illuminate/support": "^12.69|^13.0",
|
||||
"illuminate/console": "^12.69|^13.0",
|
||||
"guzzlehttp/guzzle": "^7.8",
|
||||
"socialiteproviders/manager": "^4.6",
|
||||
"firebase/php-jwt": "^6.10|^7.0"
|
||||
"firebase/php-jwt": "^7.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"laravel/framework": "^11.0|^12.0|^13.0",
|
||||
"orchestra/testbench": "^9.0|^10.0|^11.0",
|
||||
"laravel/framework": "^12.69|^13.0",
|
||||
"orchestra/testbench": "^10.0|^11.0",
|
||||
"phpunit/phpunit": "^11.0|^12.0"
|
||||
},
|
||||
"autoload": {
|
||||
|
||||
@@ -36,5 +36,16 @@ class Scope
|
||||
const SSH_KEYS = 'ssh-keys';
|
||||
const SSH_KEYS_READ = 'ssh-keys:read';
|
||||
|
||||
// Products of the Anikeen Console (servers, floating IPs, hosting, domains, zones, object storage),
|
||||
// read-only. Changing products needs `user`; there is no full `products` scope.
|
||||
const PRODUCTS_READ = 'products:read';
|
||||
|
||||
// Reseller API of the Anikeen Console. A full scope includes reading; `user` and `*` do not
|
||||
// open these endpoints, a token must carry the reseller scope itself.
|
||||
const RESELLER_CUSTOMERS = 'reseller-customers';
|
||||
const RESELLER_CUSTOMERS_READ = 'reseller-customers:read';
|
||||
const RESELLER_GRANTS = 'reseller-grants';
|
||||
const RESELLER_ORDERS = 'reseller-orders';
|
||||
|
||||
const ADMIN = 'admin';
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Anikeen\Id\Exceptions;
|
||||
|
||||
use Anikeen\Id\Result;
|
||||
use Throwable;
|
||||
|
||||
/**
|
||||
@@ -19,6 +20,30 @@ class ApiException extends ResourceException
|
||||
parent::__construct($message, $status, $previous);
|
||||
}
|
||||
|
||||
/**
|
||||
* The exception for a failed request: Anikeen ID's message and error code, the HTTP status
|
||||
* (0 when there was no response).
|
||||
*/
|
||||
public static function fromResult(Result $result): static
|
||||
{
|
||||
$body = is_object($result->data) ? $result->data : null;
|
||||
|
||||
return new static(
|
||||
rtrim((string) ($body->message ?? $result->error()), '.'),
|
||||
$result->response()?->getStatusCode() ?? 0,
|
||||
$body->error ?? null,
|
||||
$result->exception,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The exception for a request that got no response (network error, timeout).
|
||||
*/
|
||||
public static function unreachable(Throwable $previous): static
|
||||
{
|
||||
return new static(sprintf('Anikeen ID is not reachable: %s', $previous->getMessage()), 0, null, $previous);
|
||||
}
|
||||
|
||||
public function isNotFound(): bool
|
||||
{
|
||||
return $this->status === 404;
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
|
||||
namespace Anikeen\Id\Exceptions;
|
||||
|
||||
use Anikeen\Id\Result;
|
||||
use Exception;
|
||||
|
||||
class CollectionException extends Exception
|
||||
/**
|
||||
* A list request failed. Like ApiException it carries the HTTP status (also as getCode()) and
|
||||
* Anikeen ID's error code.
|
||||
*/
|
||||
class CollectionException extends ApiException
|
||||
{
|
||||
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use Anikeen\Id\Exceptions\CollectionException;
|
||||
use Anikeen\Id\Helpers\Paginator;
|
||||
use Anikeen\Id\Result;
|
||||
use Closure;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use GuzzleHttp\Psr7\Response;
|
||||
use JsonSerializable;
|
||||
use Throwable;
|
||||
@@ -34,10 +35,15 @@ abstract class BaseCollection implements JsonSerializable
|
||||
*/
|
||||
protected function __construct(callable $callable)
|
||||
{
|
||||
try {
|
||||
$this->result = $callable();
|
||||
} catch (GuzzleException $e) {
|
||||
// no response at all (connection refused, timeout)
|
||||
throw CollectionException::unreachable($e);
|
||||
}
|
||||
|
||||
if (!$this->result->success()) {
|
||||
throw new CollectionException(sprintf('%s for collection [%s]', rtrim($this->result->data->message, '.'), get_called_class()), $this->result->response->getStatusCode());
|
||||
throw CollectionException::fromResult($this->result);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -2,9 +2,10 @@
|
||||
|
||||
namespace Anikeen\Id\Resources;
|
||||
|
||||
use Anikeen\Id\Exceptions\ApiException;
|
||||
use Anikeen\Id\Exceptions\ConfirmationRequiredException;
|
||||
use Anikeen\Id\Exceptions\ResourceException;
|
||||
use Anikeen\Id\Result;
|
||||
use GuzzleHttp\Exception\GuzzleException;
|
||||
use JsonSerializable;
|
||||
|
||||
#[\AllowDynamicProperties]
|
||||
@@ -13,11 +14,17 @@ abstract class BaseResource implements JsonSerializable
|
||||
public Result $result;
|
||||
|
||||
/**
|
||||
* @throws ResourceException
|
||||
* @throws ApiException the request failed (status, error code; a ResourceException)
|
||||
* @throws ConfirmationRequiredException the user has to confirm it on Anikeen ID
|
||||
*/
|
||||
public function __construct(callable $callable)
|
||||
{
|
||||
try {
|
||||
$this->result = $callable();
|
||||
} catch (GuzzleException $e) {
|
||||
// no response at all (connection refused, timeout)
|
||||
throw ApiException::unreachable($e);
|
||||
}
|
||||
|
||||
if (!$this->result->success()) {
|
||||
if (($this->result->data->error ?? null) === 'confirmation_required') {
|
||||
@@ -28,7 +35,7 @@ abstract class BaseResource implements JsonSerializable
|
||||
);
|
||||
}
|
||||
|
||||
throw new ResourceException(sprintf('%s for resource [%s]', rtrim($this->result->data->message, '.'), get_called_class()), $this->result->response->getStatusCode());
|
||||
throw ApiException::fromResult($this->result);
|
||||
}
|
||||
|
||||
foreach ($this->result->data as $key => $value) {
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
<?php
|
||||
|
||||
namespace Anikeen\Id\Tests;
|
||||
|
||||
use Anikeen\Id\Enums\Scope;
|
||||
use PHPUnit\Framework\Attributes\DataProvider;
|
||||
use ReflectionClass;
|
||||
|
||||
class ScopeTest extends TestCase
|
||||
{
|
||||
public static function newScopes(): array
|
||||
{
|
||||
return [
|
||||
'products:read' => [Scope::PRODUCTS_READ, 'products:read'],
|
||||
'reseller-customers' => [Scope::RESELLER_CUSTOMERS, 'reseller-customers'],
|
||||
'reseller-customers:read' => [Scope::RESELLER_CUSTOMERS_READ, 'reseller-customers:read'],
|
||||
'reseller-grants' => [Scope::RESELLER_GRANTS, 'reseller-grants'],
|
||||
'reseller-orders' => [Scope::RESELLER_ORDERS, 'reseller-orders'],
|
||||
];
|
||||
}
|
||||
|
||||
#[DataProvider('newScopes')]
|
||||
public function test_the_product_and_reseller_scopes_have_their_names(string $constant, string $expected): void
|
||||
{
|
||||
$this->assertSame($expected, $constant);
|
||||
}
|
||||
|
||||
public function test_there_is_no_full_products_scope_the_console_would_not_accept(): void
|
||||
{
|
||||
// the Console's product APIs accept `user`, `user:read` and `products:read` only
|
||||
$this->assertNotContains('products', (new ReflectionClass(Scope::class))->getConstants());
|
||||
}
|
||||
|
||||
public function test_every_scope_follows_the_resource_and_level_schema(): void
|
||||
{
|
||||
$scopes = array_values((new ReflectionClass(Scope::class))->getConstants());
|
||||
|
||||
$this->assertSame($scopes, array_values(array_unique($scopes)), 'scope values must be unique');
|
||||
|
||||
foreach ($scopes as $scope) {
|
||||
// "resource" (full access), "resource:read" or "resource:client"; Anikeen ID groups by resource
|
||||
$this->assertMatchesRegularExpression('/^[a-z]+(-[a-z]+)*(:(read|client))?$/', $scope);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_each_reseller_resource_is_its_own_group(): void
|
||||
{
|
||||
$groups = collect((new ReflectionClass(Scope::class))->getConstants())
|
||||
->filter(fn (string $scope) => str_starts_with($scope, 'reseller-'))
|
||||
->groupBy(fn (string $scope) => explode(':', $scope)[0])
|
||||
->map(fn ($scopes) => $scopes->sort()->values()->all())
|
||||
->all();
|
||||
|
||||
$this->assertSame([
|
||||
'reseller-customers' => ['reseller-customers', 'reseller-customers:read'],
|
||||
'reseller-grants' => ['reseller-grants'],
|
||||
'reseller-orders' => ['reseller-orders'],
|
||||
], $groups);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
<?php
|
||||
|
||||
namespace Anikeen\Id\Tests;
|
||||
|
||||
use Anikeen\Id\Billable;
|
||||
use Anikeen\Id\Exceptions\ApiException;
|
||||
use Anikeen\Id\Exceptions\CollectionException;
|
||||
use Anikeen\Id\Exceptions\ConfirmationRequiredException;
|
||||
use Anikeen\Id\Exceptions\ResourceException;
|
||||
use GuzzleHttp\Exception\ConnectException;
|
||||
use GuzzleHttp\Psr7\Request;
|
||||
use Illuminate\Database\Eloquent\Model;
|
||||
|
||||
class UserApiExceptionsTest extends TestCase
|
||||
{
|
||||
private function user(): Model
|
||||
{
|
||||
$user = new class extends Model {
|
||||
use Billable;
|
||||
};
|
||||
$user->anikeen_id_access_token = 'user-token';
|
||||
|
||||
return $user;
|
||||
}
|
||||
|
||||
public function test_a_refused_request_carries_status_and_error_code(): void
|
||||
{
|
||||
$this->respond(200, ['data' => []]); // orders() loads the list
|
||||
$this->respond(422, ['message' => 'The billing address is missing.', 'error' => 'billing_address_missing']);
|
||||
|
||||
try {
|
||||
$this->user()->orders()->create(['items' => []]);
|
||||
$this->fail('Expected an ApiException.');
|
||||
} catch (ApiException $e) {
|
||||
$this->assertInstanceOf(ResourceException::class, $e, 'existing catch (ResourceException) blocks keep working');
|
||||
$this->assertSame(422, $e->status);
|
||||
$this->assertSame(422, $e->getCode());
|
||||
$this->assertSame('billing_address_missing', $e->error);
|
||||
$this->assertSame('The billing address is missing', $e->getMessage());
|
||||
$this->assertFalse($e->isTemporary());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_an_unknown_id_is_not_found(): void
|
||||
{
|
||||
$this->respond(200, ['data' => []]);
|
||||
$this->respond(404, ['message' => 'Not found.']);
|
||||
|
||||
try {
|
||||
$this->user()->orders()->find('01JMISSING');
|
||||
$this->fail('Expected an ApiException.');
|
||||
} catch (ApiException $e) {
|
||||
$this->assertTrue($e->isNotFound());
|
||||
$this->assertNull($e->error);
|
||||
}
|
||||
}
|
||||
|
||||
public function test_a_failed_list_is_an_api_exception_too(): void
|
||||
{
|
||||
$this->respond(503, ['message' => 'Maintenance.']);
|
||||
|
||||
try {
|
||||
$this->user()->invoices();
|
||||
$this->fail('Expected a CollectionException.');
|
||||
} catch (CollectionException $e) {
|
||||
$this->assertInstanceOf(ApiException::class, $e);
|
||||
$this->assertSame(503, $e->status);
|
||||
$this->assertTrue($e->isTemporary());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_no_response_is_temporary_with_status_zero(): void
|
||||
{
|
||||
$this->respond(200, ['data' => []]);
|
||||
$this->http->append(new ConnectException('Connection refused', new Request('GET', 'v1/orders/01J')));
|
||||
|
||||
try {
|
||||
$this->user()->orders()->find('01J');
|
||||
$this->fail('Expected an ApiException.');
|
||||
} catch (ApiException $e) {
|
||||
$this->assertSame(0, $e->status);
|
||||
$this->assertTrue($e->isTemporary());
|
||||
$this->assertInstanceOf(ConnectException::class, $e->getPrevious());
|
||||
}
|
||||
}
|
||||
|
||||
public function test_confirmation_required_stays_its_own_exception(): void
|
||||
{
|
||||
$this->respond(200, ['data' => []]);
|
||||
$this->respond(200, ['data' => ['id' => '01JORDER', 'status' => 'draft']]);
|
||||
$this->respond(403, ['message' => 'Confirm it on Anikeen ID.', 'error' => 'confirmation_required', 'confirmation_url' => 'https://id.test/confirm/01JORDER']);
|
||||
|
||||
$this->expectException(ConfirmationRequiredException::class);
|
||||
|
||||
$this->user()->orders()->find('01JORDER')->checkout();
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user