Compare commits

...
15 changed files with 1137 additions and 19 deletions

No files matched your search

+17 -2
View File
@@ -1,8 +1,9 @@
# Changelog # Changelog
## Unreleased ## 3.4.0 (2026-11-07)
Preferences, charges and the billing limit; they need an Anikeen ID release that supports them. Preferences, charges, the billing limit, user profiles and the user and preference webhooks; they
need an Anikeen ID release that supports them.
### Added ### Added
@@ -27,6 +28,20 @@ Preferences, charges and the billing limit; they need an Anikeen ID release that
(`EventType::BILLING_LIMIT_REACHED`, `BILLING_LIMIT_AVAILABLE`). (`EventType::BILLING_LIMIT_REACHED`, `BILLING_LIMIT_AVAILABLE`).
- An optional trailing `$headers` argument on `AnikeenId::request()`, `get()`, `post()`, `put()`, - An optional trailing `$headers` argument on `AnikeenId::request()`, `get()`, `post()`, `put()`,
`delete()` and `AppRequest::send()`; calls without it send the same request as before. `delete()` and `AppRequest::send()`; calls without it send the same request as before.
- Profile updates with the user's token (scope `user`): `updateProfile()` (name, username, email;
a new email address waits for the user's confirmation), `uploadAvatar()` and `removeAvatar()` on
`Billable`. They return the updated profile and clear the cached `getUserData()`.
- Reading a connected user's profile by ID for trusted apps: `findUserById()` (`null` for an
unknown, deleted or unconnected user).
- `Resources\User` with `emailVerified()`, `version()` (`profile_version` or a webhook's
`version`), `pendingEmail()` and `pendingEmailExpiresAt()`; it also reads the object of a
`user.updated` event.
- `ValidationFailedException` (a subclass of `ApiException`) for 422 responses, with
`errorCodes()`, `errorCodeFor()`, `hasErrorCode()` and `errors()`; catching `ApiException` keeps
working.
- `AnikeenId::requestMultipart()` for file uploads.
- Opt-in webhook events `user.updated`, `user.deleted` and `preference.updated`
(`EventType::USER_UPDATED`, `USER_DELETED`, `PREFERENCE_UPDATED`).
## 3.3.0 (2026-11-06) ## 3.3.0 (2026-11-06)
+161 -7
View File
@@ -16,12 +16,13 @@ subscriptions and physical goods, with invoices, VAT and payment handled by Anik
3. [Sign in with Anikeen ID](#sign-in-with-anikeen-id) 3. [Sign in with Anikeen ID](#sign-in-with-anikeen-id)
4. [Payments](#payments) 4. [Payments](#payments)
5. [Trusted apps](#trusted-apps) 5. [Trusted apps](#trusted-apps)
6. [Preferences](#preferences) 6. [User profiles](#user-profiles)
7. [API authentication](#api-authentication) 7. [Preferences](#preferences)
8. [General](#general) 8. [API authentication](#api-authentication)
9. [Examples](#examples) 9. [General](#general)
10. [Documentation](#documentation) 10. [Examples](#examples)
11. [Development](#development) 11. [Documentation](#documentation)
12. [Development](#development)
## Installation ## Installation
@@ -182,6 +183,8 @@ A full reseller scope includes reading. `user` and `*` do not open the reseller
token has to carry the reseller scope itself. For the product APIs there is only `products:read`; token has to carry the reseller scope itself. For the product APIs there is only `products:read`;
changing products needs `user`. changing products needs `user`.
Changing the user's [profile](#user-profiles) needs `Scope::USER` (`user`); `user:read` only reads.
Anikeen ID's [preferences](#preferences) use these scopes: Anikeen ID's [preferences](#preferences) use these scopes:
| Scope | Constant | Grants | | Scope | Constant | Grants |
@@ -571,6 +574,71 @@ refund or a partial cancellation, with a credit note); the order carries its `re
`refunded_total`. A full refund also cancels the order, so `order.cancelled` is sent too; don't rely on the `refunded_total`. A full refund also cancels the order, so `order.cancelled` is sent too; don't rely on the
order of the two events. order of the two events.
#### User and preference events
These events are about the user rather than your app's orders. They are **opt-in**: select them
explicitly for your endpoint, `*` doesn't include them.
| Type | Constant | Sent to | `data` |
|------|----------|---------|--------|
| `user.updated` | `EventType::USER_UPDATED` | apps the user is connected to (untrusted apps only with a token of the user carrying `user`, `user:read` or `*`), the app that made the change included | `object`: the full profile `{object: "user", id, name, username, email, email_verified, avatar, version}`; `changed`: the changed fields, e.g. `["name", "avatar"]` (never old values) |
| `user.deleted` | `EventType::USER_DELETED` | apps the user was connected to | `object`: `{object: "user", id, deleted: true, deleted_at}` |
| `preference.updated` | `EventType::PREFERENCE_UPDATED` | [trusted](#trusted-apps) apps that may read the namespace, never the app that made the change | `object`: `{object: "preference", user_id, namespace, key, version, deleted, updated_at, updated_by: {type: "user"\|"app", client_id}}`, never the value |
```json
{"id": "01JEVT…", "type": "user.updated", "created": 1762500000,
"data": {"object": {"object": "user", "id": "01JUSER…", "name": "Erika Mustermann", "username": "erika",
"email": "erika@example.com", "email_verified": true,
"avatar": "https://id.anikeen.com/avatars/01JUSER…", "version": 8},
"changed": ["name", "avatar"]}}
```
- **`user.updated`** is sent for changes to `name`, `username`, the verified email address,
`email_verified` and the profile picture, from any source (the user's settings, the
[profile API](#changing-the-profile-user-token), support). **A new email address arrives only
once it is verified**: `email` is `null` while the address isn't, and a requested change sends
nothing until the user confirms it (then `changed` is `["email", "email_verified"]` or
`["email"]`). `avatar` is a stable URL; `avatar` in `changed` means the picture behind it changed:
drop a cached copy. `new User($object)` (`Anikeen\Id\Resources\User`) reads the object.
- **Versions:** `version` grows with every such change (`profile_version` in the REST shapes).
Events can arrive late or out of order: keep the highest version you have seen and ignore older
ones. A missed event can be caught up with [`findUserById()`](#reading-a-user-by-id-trusted-apps).
- **`user.deleted`** is final and sent once: delete or anonymise your copy of the account (and
what you keep only for it), and stop acting for the user. No other event about the user follows
it, also none still queued. If a `user.updated` arrives later for the same ID, support restored
the account: treat it as a returning user.
- **`preference.updated`** is thin: fetch the preference again (`preferenceByUserId()`) or drop your
copy when `deleted` is `true`, and ignore events with a lower `version` than the one you hold
(versions are per user). Nothing is sent for a write that changes nothing, and none when the
account is deleted (`user.deleted` covers that).
```php
use Anikeen\Id\Resources\User;
$object = $event['data']['object'];
if ($event['type'] === EventType::USER_UPDATED) {
$profile = new User($object);
$account = Account::firstWhere('anikeen_id', $profile->id());
if ($account && $profile->version() > $account->profile_version) {
$account->update([
'name' => $profile->name(),
'email' => $profile->email() ?? $account->email, // null while unverified
'profile_version' => $profile->version(),
]);
}
}
if ($event['type'] === EventType::USER_DELETED) {
Account::firstWhere('anikeen_id', $object['id'])?->anonymise();
}
if ($event['type'] === EventType::PREFERENCE_UPDATED) {
Cache::forget("preferences:{$object['user_id']}");
}
```
### Errors ### Errors
Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as
@@ -587,6 +655,11 @@ well as with the app's (`app*()`). It carries:
- `body()`: the error response, for errors with details (e.g. `remaining` of - `body()`: the error response, for errors with details (e.g. `remaining` of
`billing_limit_reached`). `billing_limit_reached`).
Refused input (422) throws `ValidationFailedException`, a subclass of `ApiException`. Endpoints
with codes per field (the [profile API](#changing-the-profile-user-token)) fill `errorCodes()`
(`['username' => 'username_taken']`), `errorCodeFor($field)` and `hasErrorCode($code)`; `errors()`
has the messages per field.
```php ```php
use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Exceptions\ApiException;
@@ -1065,6 +1138,81 @@ $result->data->invitation_sent; // only for an account created by this ca
other than an unknown address. other than an unknown address.
## User profiles
### Changing the profile (user token)
With a token of the user carrying `Scope::USER` (`user`), your app can change the user's name,
username, email address and profile picture. Each call returns the updated profile
(`Anikeen\Id\Resources\User`) and clears the cached `getUserData()`.
```php
use Anikeen\Id\Exceptions\ValidationFailedException;
try {
$profile = $user->updateProfile([ // only the fields sent change
'name' => 'Erika Mustermann',
'username' => 'erika',
'email' => 'erika.new@example.com',
]);
} catch (ValidationFailedException $e) {
return back()->withErrors(match ($e->errorCodeFor('username')) {
'username_taken' => ['username' => 'This username is taken.'],
default => $e->errors(),
});
}
$profile->email(); // still the current address
$profile->pendingEmail(); // "erika.new@example.com" until the user confirms it
$profile->pendingEmailExpiresAt();
$user->uploadAvatar($request->file('avatar')); // a path or a file
$user->uploadAvatar($path, ['x' => 120, 'y' => 40, 'size' => 512]); // a square, in pixels
$user->removeAvatar(); // back to the initials
```
- **Email addresses** never change right away. Anikeen ID mails a confirmation link to the new
address and a notice to the current one; the new address becomes the user's address only once
they confirm it, signed in to Anikeen ID. Until then the current one stays valid for sign-in and
mails, and `user.updated` follows the confirmation. A new request replaces a pending one; a
pending address is dropped after 7 days (`pendingEmailExpiresAt()`).
- **Pictures:** jpeg, png, webp or gif, at most 5 MB and 64 to 8000 pixels per side; without
`crop` the centre is used. The `avatar` URL stays the same when the picture changes.
- **Limits:** 30 requests a minute per app and user, new email addresses 5 an hour and picture
uploads 10 an hour per user (429 `too_many_requests` with `Retry-After`). Requests with an address
that is taken count too.
| Status | `error` |
|--------|---------|
| 401 | `unauthenticated` |
| 403 | `missing_scope` (no `user` scope), `user_token_required` (an app token: there is no user to change) |
| 413 | `avatar_too_large` (the upload is larger than the server accepts) |
| 422 | `ValidationFailedException`, codes per field: `name_invalid`, `username_taken`, `username_invalid`, `email_taken`, `email_invalid`, `avatar_invalid`, `avatar_too_large`, `crop_invalid` (keyed by `crop.x`, `crop.y` or `crop.size`; `errorCodeFor('crop')` finds it), else `validation_failed` |
| 429 | `too_many_requests` |
Branch on the codes, the messages are for people and may change.
### Reading a user by ID (trusted apps)
[Trusted apps](#trusted-apps) can read the profile of a user connected to them (a token of the
user, a subscription or an order of the app) with the app's own token, e.g. to catch up on a
missed `user.updated`:
```php
$profile = app(AnikeenId::class)->findUserById($user->anikeen_id); // null: unknown, deleted or not connected
$profile?->name();
$profile?->email(); // returned even while unverified
$profile?->emailVerified();
$profile?->avatar();
$profile?->version(); // compare with the version of user.updated
$profile?->locale; // also username, created_at
```
It throws `ApiException` for 403 `client_not_allowed` (not a trusted app), 429
`too_many_requests` and other failures. The profile carries no billing or account data; read those
through their own endpoints.
## Preferences ## Preferences
Anikeen ID keeps small per-user settings as JSON values under a *namespace* and a *key*, so they Anikeen ID keeps small per-user settings as JSON values under a *namespace* and a *key*, so they
@@ -1205,7 +1353,9 @@ Anikeen ID is the source of truth. `globalPreferences()` and `globalPreference()
on the model instance (one request per instance); `setGlobalPreference()` and on the model instance (one request per instance); `setGlobalPreference()` and
`deleteGlobalPreference()` clear it. For a copy that outlives the request, keep a read cache: fill `deleteGlobalPreference()` clear it. For a copy that outlives the request, keep a read cache: fill
it at sign-in, write to Anikeen ID first and update the copy from the response, and let it expire it at sign-in, write to Anikeen ID first and update the copy from the response, and let it expire
after about an hour, since other apps and the user can change shared preferences too: after about an hour, since other apps and the user can change shared preferences too (trusted
apps can select the [`preference.updated`](#user-and-preference-events) webhook to drop it
sooner):
```php ```php
$appearance = Cache::remember("appearance:{$user->id}", now()->addHour(), $appearance = Cache::remember("appearance:{$user->id}", now()->addHour(),
@@ -1447,6 +1597,7 @@ public function sshKeysByUserId(string $userId): SshKeys
public function getAuthedUser(): Result public function getAuthedUser(): Result
public function createUser(array $attributes): Result public function createUser(array $attributes): Result
public function findUserByEmail(string $email): ?Result public function findUserByEmail(string $email): ?Result
public function findUserById(string $userId): ?User
public function findOrCreateSocialiteUser(string $provider, string $providerId, ?string $email = null, ?string $name = null): Result public function findOrCreateSocialiteUser(string $provider, string $providerId, ?string $email = null, ?string $name = null): Result
public function refreshToken(string $storedRefreshToken, string $scope = ''): Result public function refreshToken(string $storedRefreshToken, string $scope = ''): Result
public function isEmailExisting(string $email): Result public function isEmailExisting(string $email): Result
@@ -1519,6 +1670,9 @@ public function getBillable(): Billable
```php ```php
public function profilePortalUrl(?string $returnUrl = null, array $options = []): string public function profilePortalUrl(?string $returnUrl = null, array $options = []): string
public function updateProfile(array $attributes): User
public function uploadAvatar(SplFileInfo|string $file, ?array $crop = null): User
public function removeAvatar(): User
public function setBillable(Billable|Model $billable): self public function setBillable(Billable|Model $billable): self
public function getBillable(): Billable public function getBillable(): Billable
``` ```
+157 -7
View File
@@ -16,12 +16,13 @@ subscriptions and physical goods, with invoices, VAT and payment handled by Anik
3. [Sign in with Anikeen ID](#sign-in-with-anikeen-id) 3. [Sign in with Anikeen ID](#sign-in-with-anikeen-id)
4. [Payments](#payments) 4. [Payments](#payments)
5. [Trusted apps](#trusted-apps) 5. [Trusted apps](#trusted-apps)
6. [Preferences](#preferences) 6. [User profiles](#user-profiles)
7. [API authentication](#api-authentication) 7. [Preferences](#preferences)
8. [General](#general) 8. [API authentication](#api-authentication)
9. [Examples](#examples) 9. [General](#general)
10. [Documentation](#documentation) 10. [Examples](#examples)
11. [Development](#development) 11. [Documentation](#documentation)
12. [Development](#development)
## Installation ## Installation
@@ -182,6 +183,8 @@ A full reseller scope includes reading. `user` and `*` do not open the reseller
token has to carry the reseller scope itself. For the product APIs there is only `products:read`; token has to carry the reseller scope itself. For the product APIs there is only `products:read`;
changing products needs `user`. changing products needs `user`.
Changing the user's [profile](#user-profiles) needs `Scope::USER` (`user`); `user:read` only reads.
Anikeen ID's [preferences](#preferences) use these scopes: Anikeen ID's [preferences](#preferences) use these scopes:
| Scope | Constant | Grants | | Scope | Constant | Grants |
@@ -571,6 +574,71 @@ refund or a partial cancellation, with a credit note); the order carries its `re
`refunded_total`. A full refund also cancels the order, so `order.cancelled` is sent too; don't rely on the `refunded_total`. A full refund also cancels the order, so `order.cancelled` is sent too; don't rely on the
order of the two events. order of the two events.
#### User and preference events
These events are about the user rather than your app's orders. They are **opt-in**: select them
explicitly for your endpoint, `*` doesn't include them.
| Type | Constant | Sent to | `data` |
|------|----------|---------|--------|
| `user.updated` | `EventType::USER_UPDATED` | apps the user is connected to (untrusted apps only with a token of the user carrying `user`, `user:read` or `*`), the app that made the change included | `object`: the full profile `{object: "user", id, name, username, email, email_verified, avatar, version}`; `changed`: the changed fields, e.g. `["name", "avatar"]` (never old values) |
| `user.deleted` | `EventType::USER_DELETED` | apps the user was connected to | `object`: `{object: "user", id, deleted: true, deleted_at}` |
| `preference.updated` | `EventType::PREFERENCE_UPDATED` | [trusted](#trusted-apps) apps that may read the namespace, never the app that made the change | `object`: `{object: "preference", user_id, namespace, key, version, deleted, updated_at, updated_by: {type: "user"\|"app", client_id}}`, never the value |
```json
{"id": "01JEVT…", "type": "user.updated", "created": 1762500000,
"data": {"object": {"object": "user", "id": "01JUSER…", "name": "Erika Mustermann", "username": "erika",
"email": "erika@example.com", "email_verified": true,
"avatar": "https://id.anikeen.com/avatars/01JUSER…", "version": 8},
"changed": ["name", "avatar"]}}
```
- **`user.updated`** is sent for changes to `name`, `username`, the verified email address,
`email_verified` and the profile picture, from any source (the user's settings, the
[profile API](#changing-the-profile-user-token), support). **A new email address arrives only
once it is verified**: `email` is `null` while the address isn't, and a requested change sends
nothing until the user confirms it (then `changed` is `["email", "email_verified"]` or
`["email"]`). `avatar` is a stable URL; `avatar` in `changed` means the picture behind it changed:
drop a cached copy. `new User($object)` (`Anikeen\Id\Resources\User`) reads the object.
- **Versions:** `version` grows with every such change (`profile_version` in the REST shapes).
Events can arrive late or out of order: keep the highest version you have seen and ignore older
ones. A missed event can be caught up with [`findUserById()`](#reading-a-user-by-id-trusted-apps).
- **`user.deleted`** is final and sent once: delete or anonymise your copy of the account (and
what you keep only for it), and stop acting for the user. No other event about the user follows
it, also none still queued. If a `user.updated` arrives later for the same ID, support restored
the account: treat it as a returning user.
- **`preference.updated`** is thin: fetch the preference again (`preferenceByUserId()`) or drop your
copy when `deleted` is `true`, and ignore events with a lower `version` than the one you hold
(versions are per user). Nothing is sent for a write that changes nothing, and none when the
account is deleted (`user.deleted` covers that).
```php
use Anikeen\Id\Resources\User;
$object = $event['data']['object'];
if ($event['type'] === EventType::USER_UPDATED) {
$profile = new User($object);
$account = Account::firstWhere('anikeen_id', $profile->id());
if ($account && $profile->version() > $account->profile_version) {
$account->update([
'name' => $profile->name(),
'email' => $profile->email() ?? $account->email, // null while unverified
'profile_version' => $profile->version(),
]);
}
}
if ($event['type'] === EventType::USER_DELETED) {
Account::firstWhere('anikeen_id', $object['id'])?->anonymise();
}
if ($event['type'] === EventType::PREFERENCE_UPDATED) {
Cache::forget("preferences:{$object['user_id']}");
}
```
### Errors ### Errors
Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as
@@ -587,6 +655,11 @@ well as with the app's (`app*()`). It carries:
- `body()`: the error response, for errors with details (e.g. `remaining` of - `body()`: the error response, for errors with details (e.g. `remaining` of
`billing_limit_reached`). `billing_limit_reached`).
Refused input (422) throws `ValidationFailedException`, a subclass of `ApiException`. Endpoints
with codes per field (the [profile API](#changing-the-profile-user-token)) fill `errorCodes()`
(`['username' => 'username_taken']`), `errorCodeFor($field)` and `hasErrorCode($code)`; `errors()`
has the messages per field.
```php ```php
use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Exceptions\ApiException;
@@ -1065,6 +1138,81 @@ $result->data->invitation_sent; // only for an account created by this ca
other than an unknown address. other than an unknown address.
## User profiles
### Changing the profile (user token)
With a token of the user carrying `Scope::USER` (`user`), your app can change the user's name,
username, email address and profile picture. Each call returns the updated profile
(`Anikeen\Id\Resources\User`) and clears the cached `getUserData()`.
```php
use Anikeen\Id\Exceptions\ValidationFailedException;
try {
$profile = $user->updateProfile([ // only the fields sent change
'name' => 'Erika Mustermann',
'username' => 'erika',
'email' => 'erika.new@example.com',
]);
} catch (ValidationFailedException $e) {
return back()->withErrors(match ($e->errorCodeFor('username')) {
'username_taken' => ['username' => 'This username is taken.'],
default => $e->errors(),
});
}
$profile->email(); // still the current address
$profile->pendingEmail(); // "erika.new@example.com" until the user confirms it
$profile->pendingEmailExpiresAt();
$user->uploadAvatar($request->file('avatar')); // a path or a file
$user->uploadAvatar($path, ['x' => 120, 'y' => 40, 'size' => 512]); // a square, in pixels
$user->removeAvatar(); // back to the initials
```
- **Email addresses** never change right away. Anikeen ID mails a confirmation link to the new
address and a notice to the current one; the new address becomes the user's address only once
they confirm it, signed in to Anikeen ID. Until then the current one stays valid for sign-in and
mails, and `user.updated` follows the confirmation. A new request replaces a pending one; a
pending address is dropped after 7 days (`pendingEmailExpiresAt()`).
- **Pictures:** jpeg, png, webp or gif, at most 5 MB and 64 to 8000 pixels per side; without
`crop` the centre is used. The `avatar` URL stays the same when the picture changes.
- **Limits:** 30 requests a minute per app and user, new email addresses 5 an hour and picture
uploads 10 an hour per user (429 `too_many_requests` with `Retry-After`). Requests with an address
that is taken count too.
| Status | `error` |
|--------|---------|
| 401 | `unauthenticated` |
| 403 | `missing_scope` (no `user` scope), `user_token_required` (an app token: there is no user to change) |
| 413 | `avatar_too_large` (the upload is larger than the server accepts) |
| 422 | `ValidationFailedException`, codes per field: `name_invalid`, `username_taken`, `username_invalid`, `email_taken`, `email_invalid`, `avatar_invalid`, `avatar_too_large`, `crop_invalid` (keyed by `crop.x`, `crop.y` or `crop.size`; `errorCodeFor('crop')` finds it), else `validation_failed` |
| 429 | `too_many_requests` |
Branch on the codes, the messages are for people and may change.
### Reading a user by ID (trusted apps)
[Trusted apps](#trusted-apps) can read the profile of a user connected to them (a token of the
user, a subscription or an order of the app) with the app's own token, e.g. to catch up on a
missed `user.updated`:
```php
$profile = app(AnikeenId::class)->findUserById($user->anikeen_id); // null: unknown, deleted or not connected
$profile?->name();
$profile?->email(); // returned even while unverified
$profile?->emailVerified();
$profile?->avatar();
$profile?->version(); // compare with the version of user.updated
$profile?->locale; // also username, created_at
```
It throws `ApiException` for 403 `client_not_allowed` (not a trusted app), 429
`too_many_requests` and other failures. The profile carries no billing or account data; read those
through their own endpoints.
## Preferences ## Preferences
Anikeen ID keeps small per-user settings as JSON values under a *namespace* and a *key*, so they Anikeen ID keeps small per-user settings as JSON values under a *namespace* and a *key*, so they
@@ -1205,7 +1353,9 @@ Anikeen ID is the source of truth. `globalPreferences()` and `globalPreference()
on the model instance (one request per instance); `setGlobalPreference()` and on the model instance (one request per instance); `setGlobalPreference()` and
`deleteGlobalPreference()` clear it. For a copy that outlives the request, keep a read cache: fill `deleteGlobalPreference()` clear it. For a copy that outlives the request, keep a read cache: fill
it at sign-in, write to Anikeen ID first and update the copy from the response, and let it expire it at sign-in, write to Anikeen ID first and update the copy from the response, and let it expire
after about an hour, since other apps and the user can change shared preferences too: after about an hour, since other apps and the user can change shared preferences too (trusted
apps can select the [`preference.updated`](#user-and-preference-events) webhook to drop it
sooner):
```php ```php
$appearance = Cache::remember("appearance:{$user->id}", now()->addHour(), $appearance = Cache::remember("appearance:{$user->id}", now()->addHour(),
+26
View File
@@ -397,6 +397,32 @@ class AnikeenId
return $result; return $result;
} }
/**
* Send a multipart/form-data request (file uploads), with the same headers and error handling
* as request(), which stays JSON only.
*
* @param list<array{name: string, contents: mixed, filename?: string, headers?: array<string, string>}> $multipart
* Guzzle's multipart parts
* @throws GuzzleException
* @throws RequestRequiresClientIdException
*/
public function requestMultipart(string $method, string $path, array $multipart): Result
{
try {
$response = $this->client->request($method, $path, [
// no Content-Type: Guzzle sets it with the boundary
'headers' => $this->buildHeaders(),
'multipart' => $multipart,
]);
$result = new Result($response, null, $this);
} catch (RequestException $exception) {
$result = new Result($exception->getResponse(), $exception, $this);
}
return $result;
}
/** /**
* Build headers for request. * Build headers for request.
* *
+108 -1
View File
@@ -2,8 +2,19 @@
namespace Anikeen\Id\Concerns; namespace Anikeen\Id\Concerns;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\ValidationFailedException;
use Anikeen\Id\Resources\User;
use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException;
use GuzzleHttp\Psr7\Utils;
use SplFileInfo;
use Throwable; use Throwable;
/**
* The user's own profile, with the user's token. Changes need the scope `user` (`user:read`
* only reads; 403 "missing_scope").
*/
trait ManagesProfile trait ManagesProfile
{ {
use HasBillable; use HasBillable;
@@ -23,4 +34,100 @@ trait ManagesProfile
'options' => $options, 'options' => $options,
])->data->url; ])->data->url;
} }
}
/**
* Change the user's profile (scope `user`). Only the fields sent change. A new email address
* doesn't replace the current one: Anikeen ID mails a confirmation link to it, and it becomes
* the user's address once they confirm it signed in to Anikeen ID (see pendingEmail() of the
* result; user.updated follows then). Clears the cached getUserData().
*
* @param array{
* name?: string,
* username?: string,
* email?: string
* } $attributes The fields to change
* - name: The display name
* - username: The username (unique regardless of case)
* - email: A new email address (lower-cased; needs the user's confirmation)
* @return User the user as `GET v1/user` returns them, plus `pending_email` and
* `pending_email_expires_at`
* @throws ValidationFailedException 422 with the codes "name_invalid", "username_taken",
* "username_invalid", "email_taken", "email_invalid" (errorCodeFor($field))
* @throws ApiException 401 "unauthenticated", 403 "missing_scope", "user_token_required",
* 429 "too_many_requests" (30 a minute per app and user, new email addresses 5 an hour)
*/
public function updateProfile(array $attributes): User
{
$anikeenId = $this->anikeenId();
return $this->sendProfileRequest(fn () => $anikeenId->request('PATCH', 'v1/user/profile', $attributes));
}
/**
* Upload a new profile picture (scope `user`): jpeg, png, webp or gif, at most 5 MB, 64 to
* 8000 pixels per side. The avatar URL stays the same, the picture behind it changes. Clears
* the cached getUserData().
*
* @param string|SplFileInfo $file a path or a file, e.g. an UploadedFile
* @param array{x: int, y: int, size: int}|null $crop the square to use, in pixels of the
* upright picture; the centre without one
* @throws ValidationFailedException 422 with the codes "avatar_invalid", "avatar_too_large",
* "crop_invalid" (keyed by crop.x, crop.y or crop.size; errorCodeFor('crop') finds them)
* @throws ApiException 413 "avatar_too_large", 401, 403 "missing_scope", "user_token_required",
* 429 "too_many_requests" (also more than 10 uploads an hour)
* @throws \RuntimeException when the file can't be opened (nothing is sent then)
*/
public function uploadAvatar(string|SplFileInfo $file, ?array $crop = null): User
{
$path = $file instanceof SplFileInfo ? $file->getPathname() : $file;
$filename = $file instanceof SplFileInfo && method_exists($file, 'getClientOriginalName')
? $file->getClientOriginalName()
: basename($path);
$multipart = [['name' => 'avatar', 'contents' => Utils::tryFopen($path, 'r'), 'filename' => $filename]];
foreach (['x', 'y', 'size'] as $key) {
if (isset($crop[$key])) {
$multipart[] = ['name' => "crop[{$key}]", 'contents' => (string) $crop[$key]];
}
}
$anikeenId = $this->anikeenId();
return $this->sendProfileRequest(fn () => $anikeenId->requestMultipart('POST', 'v1/user/profile/avatar', $multipart));
}
/**
* Remove the uploaded profile picture (scope `user`); the initials are shown again when it
* was the one shown. Clears the cached getUserData().
*
* @throws ApiException 401, 403 "missing_scope", "user_token_required", 429 "too_many_requests"
*/
public function removeAvatar(): User
{
$anikeenId = $this->anikeenId();
return $this->sendProfileRequest(fn () => $anikeenId->request('DELETE', 'v1/user/profile/avatar'));
}
/**
* @param callable(): Result $send
* @throws ApiException
*/
private function sendProfileRequest(callable $send): User
{
try {
$result = $send();
} catch (GuzzleException $e) {
throw ApiException::unreachable($e);
} finally {
$this->userDataCache = null;
}
if (!$result->success()) {
throw ApiException::fromResult($result);
}
return new User($result->data);
}
}␍
+25
View File
@@ -6,6 +6,7 @@ namespace Anikeen\Id\Concerns;
use Anikeen\Id\ApiOperations\Get; use Anikeen\Id\ApiOperations\Get;
use Anikeen\Id\ApiOperations\Post; use Anikeen\Id\ApiOperations\Post;
use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\User;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use Anikeen\Id\Support\AppRequest; use Anikeen\Id\Support\AppRequest;
use Throwable; use Throwable;
@@ -95,6 +96,30 @@ trait ManagesUsers
} }
} }
/**
* The profile of a user connected to your app, by their Anikeen ID (trusted apps, app token),
* e.g. to catch up on a missed user.updated webhook. The email address is returned even while
* it isn't verified (emailVerified() is false then). No scope needed.
*
* @return User|null null when the user is unknown, deleted or not connected to your app
* (404 "user_not_found"; the three look the same)
* @throws ApiException 403 "client_not_allowed" (not a trusted app), 429 "too_many_requests",
* or another failure
*/
public function findUserById(string $userId): ?User
{
try {
return new User(AppRequest::send('GET', sprintf('v1/client/users/%s', rawurlencode($userId)))->data);
} catch (ApiException $e) {
// only Anikeen ID's own answer means "no such user", not e.g. an unknown route
if ($e->isNotFound() && $e->error === 'user_not_found') {
return null;
}
throw $e;
}
}
/** /**
* Find the user of a social login or create it (trusted apps, app token). * Find the user of a social login or create it (trusted apps, app token).
* *
+5
View File
@@ -35,6 +35,11 @@ class ApiException extends ResourceException
return PreconditionFailedException::fromResult($result); return PreconditionFailedException::fromResult($result);
} }
// refused input, with per-field codes on some endpoints
if ($status === 422 && static::class === self::class) {
return ValidationFailedException::fromResult($result);
}
return new static( return new static(
rtrim((string) ($body->message ?? $result->error()), '.'), rtrim((string) ($body->message ?? $result->error()), '.'),
$status, $status,
@@ -0,0 +1,62 @@
<?php
namespace Anikeen\Id\Exceptions;
/**
* Anikeen ID refused the input (422). `error` is the first code; endpoints with per-field codes
* (the profile API) send them as `codes`: `{field: code}`, e.g. `["username" => "username_taken"]`.
* Branch on the codes, the messages are for people and may change.
*/
class ValidationFailedException extends ApiException
{
/**
* @return array<string, string> field => code; empty when the endpoint sends none
*/
public function errorCodes(): array
{
$codes = $this->body()->codes ?? null;
return is_object($codes) || is_array($codes) ? array_map('strval', (array) $codes) : [];
}
/**
* The code of $field, or of its first failed sub-field when the codes are keyed by those
* (`crop` finds `crop.x`, `crop.y` or `crop.size`).
*/
public function errorCodeFor(string $field): ?string
{
$codes = $this->errorCodes();
if (isset($codes[$field])) {
return $codes[$field];
}
foreach ($codes as $key => $code) {
if (str_starts_with((string) $key, $field . '.')) {
return $code;
}
}
return null;
}
/**
* Whether any field failed with $code, or the response's `error` is $code.
*/
public function hasErrorCode(string $code): bool
{
return $this->error === $code || in_array($code, $this->errorCodes(), true);
}
/**
* The messages per field (`errors`), for showing them to people.
*
* @return array<string, list<string>>
*/
public function errors(): array
{
$errors = $this->body()->errors ?? null;
return is_object($errors) || is_array($errors) ? array_map(fn ($messages) => array_values((array) $messages), (array) $errors) : [];
}
}
+123
View File
@@ -0,0 +1,123 @@
<?php
namespace Anikeen\Id\Resources;
use DateTimeImmutable;
use JsonSerializable;
/**
* A user's profile: AnikeenId::findUserById(), the result of the profile updates on Billable
* (updateProfile(), uploadAvatar(), removeAvatar()) and the `data.object` of a user.updated
* webhook (`new User($event['data']['object'])`).
*
* The REST shapes carry `email_verified_at` and `profile_version`, the webhook `email_verified`
* and `version`; emailVerified() and version() read either. The profile updates also return the
* other fields of `GET v1/user` plus `pending_email` and `pending_email_expires_at`.
*
* @property string $id
* @property ?string $name
* @property ?string $username
* @property ?string $email in a webhook only once verified (null while it isn't)
* @property ?string $email_verified_at REST shapes
* @property bool $email_verified webhook
* @property ?string $avatar a stable URL; the picture behind it can change
* @property ?string $locale REST shapes
* @property int $profile_version REST shapes
* @property int $version webhook
* @property ?string $created_at REST shapes
* @property ?string $pending_email profile updates only: a new address waiting for confirmation
* @property ?string $pending_email_expires_at profile updates only
*/
class User implements JsonSerializable
{
protected object $data;
public function __construct(object|array $data)
{
$this->data = (object) $data;
}
public function __get(string $name): mixed
{
return $this->data->{$name} ?? null;
}
public function __isset(string $name): bool
{
return isset($this->data->{$name});
}
public function id(): string
{
return (string) ($this->data->id ?? '');
}
public function name(): ?string
{
return $this->data->name ?? null;
}
public function username(): ?string
{
return $this->data->username ?? null;
}
public function email(): ?string
{
return $this->data->email ?? null;
}
/**
* From `email_verified` (webhooks) or `email_verified_at` (REST shapes).
*/
public function emailVerified(): bool
{
if (isset($this->data->email_verified)) {
return (bool) $this->data->email_verified;
}
return !empty($this->data->email_verified_at);
}
/**
* The URL of the profile picture. It stays the same when the picture changes (user.updated
* with `avatar` in `changed`): drop a cached copy then.
*/
public function avatar(): ?string
{
return $this->data->avatar ?? null;
}
/**
* Grows with every change apps see (`profile_version` in REST shapes, `version` in webhooks):
* keep the highest one seen and ignore older data.
*/
public function version(): int
{
return (int) ($this->data->profile_version ?? $this->data->version ?? 0);
}
/**
* A new email address waiting for the user's confirmation (profile updates only); `email`
* stays the current address until then.
*/
public function pendingEmail(): ?string
{
return $this->data->pending_email ?? null;
}
public function pendingEmailExpiresAt(): ?DateTimeImmutable
{
return !empty($this->data->pending_email_expires_at) ? new DateTimeImmutable($this->data->pending_email_expires_at) : null;
}
public function toArray(): array
{
return (array) $this->data;
}
public function jsonSerialize(): array
{
return $this->toArray();
}
}
+7 -2
View File
@@ -6,6 +6,7 @@ use Anikeen\Id\AnikeenId;
use Anikeen\Id\Contracts\AppTokenRepository; use Anikeen\Id\Contracts\AppTokenRepository;
use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\PreconditionFailedException; use Anikeen\Id\Exceptions\PreconditionFailedException;
use Anikeen\Id\Exceptions\ValidationFailedException;
use Anikeen\Id\Result; use Anikeen\Id\Result;
use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Exception\GuzzleException;
use Throwable; use Throwable;
@@ -13,7 +14,7 @@ use Throwable;
/** /**
* Sends a request with the app's own token (client credentials). A rejected token is renewed * Sends a request with the app's own token (client credentials). A rejected token is renewed
* once; every failure ends in an ApiException with status and error code (a 412 in a * once; every failure ends in an ApiException with status and error code (a 412 in a
* PreconditionFailedException). * PreconditionFailedException, a 422 in a ValidationFailedException).
*/ */
final class AppRequest final class AppRequest
{ {
@@ -36,7 +37,11 @@ final class AppRequest
if (!$result->success()) { if (!$result->success()) {
$body = is_object($result->data) ? $result->data : null; $body = is_object($result->data) ? $result->data : null;
$exception = $result->status === 412 ? PreconditionFailedException::class : ApiException::class; $exception = match ($result->status) {
412 => PreconditionFailedException::class,
422 => ValidationFailedException::class,
default => ApiException::class,
};
throw new $exception( throw new $exception(
sprintf('Anikeen ID request %s %s failed: %s', $method, $path, $body->message ?? $result->error()), sprintf('Anikeen ID request %s %s failed: %s', $method, $path, $body->message ?? $result->error()),
+28
View File
@@ -76,6 +76,34 @@ final class EventType
*/ */
public const BILLING_LIMIT_AVAILABLE = 'billing_limit.available'; public const BILLING_LIMIT_AVAILABLE = 'billing_limit.available';
/**
* A preference of a user was set, changed or deleted. Thin, never the value: `data.object` is
* `{object: "preference", user_id, namespace, key, version, deleted, updated_at, updated_by:
* {type: "user"|"app", client_id}}`; fetch the preference again (or drop your copy when
* `deleted`) and ignore events with a lower version than the one you hold. Opt-in (not through
* `*`); only to trusted apps that may read the namespace, never to the app that made the change.
*/
public const PREFERENCE_UPDATED = 'preference.updated';
/**
* The profile of a user connected to your app changed: name, username, the verified email
* address, email_verified or the avatar. `data.object` is the full profile `{object: "user", id,
* name, username, email, email_verified, avatar, version}` (Resources\User), `data.changed` names
* the changed fields (never their old values). An email address appears only once verified
* (`email` is null while it isn't); `avatar` in `changed` means the picture behind the stable URL
* changed. Keep the highest `version` and ignore older events. Opt-in (not through `*`); also
* sent to the app that made the change.
*/
public const USER_UPDATED = 'user.updated';
/**
* A user connected to your app deleted their account (or it was deleted): delete or anonymise
* it and treat it as final. `data.object` is `{object: "user", id, deleted: true, deleted_at}`.
* Sent once; no other event about the user follows. A later user.updated for the same ID means
* the account was restored. Opt-in (not through `*`).
*/
public const USER_DELETED = 'user.deleted';
public const TEST = 'webhook.test'; public const TEST = 'webhook.test';
/** /**
+101
View File
@@ -0,0 +1,101 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Resources\User;
use Anikeen\Id\Webhooks\EventType;
class UserByIdTest extends TestCase
{
private function sentCall(int $index): string
{
$request = $this->sent[$index]['request'];
return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization');
}
private function profile(array $overrides = []): array
{
return ['data' => [
'id' => '01JUSER', 'name' => 'Ada King', 'username' => 'ada', 'email' => 'ada@example.com',
'email_verified_at' => '2026-11-01T10:00:00.000000Z', 'avatar' => 'https://id.test/avatars/01JUSER',
'locale' => 'en', 'profile_version' => 8, 'created_at' => '2026-01-01T00:00:00.000000Z',
...$overrides,
]];
}
public function test_find_user_by_id_reads_a_connected_user_with_the_app_token(): void
{
$this->respond(200, $this->profile());
$user = app(AnikeenId::class)->findUserById('01JUSER');
$this->assertSame('GET /api/v1/client/users/01JUSER Bearer app-token', $this->sentCall(0));
$this->assertInstanceOf(User::class, $user);
$this->assertSame('01JUSER', $user->id());
$this->assertSame('Ada King', $user->name());
$this->assertSame('ada', $user->username());
$this->assertSame('ada@example.com', $user->email());
$this->assertTrue($user->emailVerified());
$this->assertSame('https://id.test/avatars/01JUSER', $user->avatar());
$this->assertSame(8, $user->version());
$this->assertSame('en', $user->locale);
$this->assertNull($user->pendingEmail());
$this->assertSame('Ada King', $user->jsonSerialize()['name']);
}
public function test_find_user_by_id_encodes_the_id(): void
{
$this->respond(200, $this->profile());
app(AnikeenId::class)->findUserById('a/b');
$this->assertSame('/api/v1/client/users/a%2Fb', $this->sent[0]['request']->getUri()->getPath());
}
public function test_find_user_by_id_returns_null_for_an_unknown_or_unconnected_user(): void
{
$this->respond(404, ['message' => 'User not found.', 'error' => 'user_not_found']);
$this->assertNull(app(AnikeenId::class)->findUserById('01JOTHER'));
}
public function test_find_user_by_id_throws_other_failures(): void
{
$this->respond(403, ['message' => 'Only trusted apps.', 'error' => 'client_not_allowed']);
try {
app(AnikeenId::class)->findUserById('01JUSER');
$this->fail('No exception thrown');
} catch (ApiException $e) {
$this->assertSame(403, $e->status);
$this->assertSame('client_not_allowed', $e->error);
}
// a 404 without Anikeen ID's code (e.g. an older release without the route) isn't "no user"
$this->respond(404, ['message' => 'Not Found']);
$this->expectException(ApiException::class);
app(AnikeenId::class)->findUserById('01JUSER');
}
public function test_an_unverified_address_is_not_verified(): void
{
$this->respond(200, $this->profile(['email_verified_at' => null]));
$this->assertFalse(app(AnikeenId::class)->findUserById('01JUSER')->emailVerified());
}
public function test_a_user_updated_webhook_object_is_a_user(): void
{
$user = new User(['object' => 'user', 'id' => '01JUSER', 'name' => 'Ada King', 'username' => 'ada',
'email' => null, 'email_verified' => false, 'avatar' => 'https://id.test/avatars/01JUSER', 'version' => 9]);
$this->assertSame(9, $user->version());
$this->assertFalse($user->emailVerified());
$this->assertNull($user->email());
$this->assertSame('user', $user->object);
$this->assertSame('user.updated', EventType::USER_UPDATED);
}
}
+246
View File
@@ -0,0 +1,246 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\ValidationFailedException;
use DateTimeImmutable;
use GuzzleHttp\Exception\ConnectException;
use GuzzleHttp\Psr7\Request;
use Illuminate\Database\Eloquent\Model;
class UserProfileTest extends TestCase
{
private function owner(): Model
{
$owner = new class extends Model {
use Billable;
protected $guarded = [];
};
$owner->anikeen_id = '01JUSER';
$owner->anikeen_id_access_token = 'user-token';
return $owner;
}
private function sentCall(int $index): string
{
$request = $this->sent[$index]['request'];
return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization');
}
private function profile(array $overrides = []): array
{
return ['data' => [
'id' => '01JUSER', 'name' => 'Ada King', 'username' => 'ada', 'email' => 'ada@example.com',
'email_verified_at' => '2026-11-01T10:00:00.000000Z', 'avatar' => 'https://id.test/avatars/01JUSER',
'locale' => 'en', 'profile_version' => 8, 'created_at' => '2026-01-01T00:00:00.000000Z',
...$overrides,
]];
}
private function validationError(array $codes): array
{
return [
'message' => 'The given data was invalid.',
'error' => array_values($codes)[0],
'errors' => array_map(fn () => ['The value is invalid.'], $codes),
'codes' => $codes,
];
}
public function test_update_profile_patches_the_fields_with_the_user_token(): void
{
$this->respond(200, $this->profile(['name' => 'Ada Lovelace', 'profile_version' => 9,
'pending_email' => 'new@example.com', 'pending_email_expires_at' => '2026-11-07T11:00:00.000000Z']));
$user = $this->owner()->updateProfile(['name' => 'Ada Lovelace', 'email' => 'new@example.com']);
$this->assertSame('PATCH /api/v1/user/profile Bearer user-token', $this->sentCall(0));
$this->assertSame(['name' => 'Ada Lovelace', 'email' => 'new@example.com'], $this->sentJson(0));
$this->assertSame('Ada Lovelace', $user->name());
$this->assertSame('ada@example.com', $user->email());
$this->assertSame('new@example.com', $user->pendingEmail());
$this->assertEquals(new DateTimeImmutable('2026-11-07T11:00:00Z'), $user->pendingEmailExpiresAt());
$this->assertSame(9, $user->version());
}
public function test_update_profile_clears_the_cached_user_data(): void
{
$owner = $this->owner();
$this->respond(200, $this->profile());
$this->respond(200, $this->profile(['name' => 'Ada Lovelace']));
$this->respond(200, $this->profile(['name' => 'Ada Lovelace']));
$this->assertSame('Ada King', $owner->getUserData()->name);
$owner->updateProfile(['name' => 'Ada Lovelace']);
$this->assertSame('Ada Lovelace', $owner->getUserData()->name);
$this->assertCount(3, $this->sent);
$this->assertSame('GET /api/v1/user Bearer user-token', $this->sentCall(2));
}
public function test_refused_profile_changes_carry_codes_per_field(): void
{
$this->respond(422, $this->validationError(['username' => 'username_taken', 'email' => 'email_invalid']));
try {
$this->owner()->updateProfile(['username' => 'taken', 'email' => 'nope']);
$this->fail('No exception thrown');
} catch (ValidationFailedException $e) {
$this->assertSame(422, $e->status);
$this->assertSame('username_taken', $e->error);
$this->assertSame(['username' => 'username_taken', 'email' => 'email_invalid'], $e->errorCodes());
$this->assertSame('email_invalid', $e->errorCodeFor('email'));
$this->assertNull($e->errorCodeFor('name'));
$this->assertTrue($e->hasErrorCode('email_invalid'));
$this->assertFalse($e->hasErrorCode('email_taken'));
$this->assertSame(['The value is invalid.'], $e->errors()['username']);
$this->assertInstanceOf(ApiException::class, $e);
}
}
public function test_a_client_credentials_token_is_refused(): void
{
$this->respond(403, ['message' => 'This endpoint needs an access token of the user.', 'error' => 'user_token_required']);
try {
$this->owner()->updateProfile(['name' => 'Ada']);
$this->fail('No exception thrown');
} catch (ApiException $e) {
$this->assertNotInstanceOf(ValidationFailedException::class, $e);
$this->assertSame('user_token_required', $e->error);
}
}
public function test_an_unreachable_service_is_an_api_exception(): void
{
$this->http->append(new ConnectException('Connection refused', new Request('PATCH', 'v1/user/profile')));
try {
$this->owner()->updateProfile(['name' => 'Ada']);
$this->fail('No exception thrown');
} catch (ApiException $e) {
$this->assertSame(0, $e->status);
$this->assertTrue($e->isTemporary());
}
}
public function test_upload_avatar_sends_the_picture_as_multipart(): void
{
$path = tempnam(sys_get_temp_dir(), 'avatar');
file_put_contents($path, 'picture-bytes');
$this->respond(200, $this->profile(['profile_version' => 10]));
try {
$user = $this->owner()->uploadAvatar($path, ['x' => 10, 'y' => 20, 'size' => 300]);
} finally {
unlink($path);
}
$request = $this->sent[0]['request'];
$body = (string) $request->getBody();
$this->assertSame('POST /api/v1/user/profile/avatar Bearer user-token', $this->sentCall(0));
$this->assertStringStartsWith('multipart/form-data; boundary=', $request->getHeaderLine('Content-Type'));
$this->assertStringContainsString('name="avatar"; filename="' . basename($path) . '"', $body);
$this->assertStringContainsString('picture-bytes', $body);
$this->assertStringContainsString("name=\"crop[x]\"\r\nContent-Length: 2\r\n\r\n10", $body);
$this->assertStringContainsString('name="crop[y]"', $body);
$this->assertStringContainsString('name="crop[size]"', $body);
$this->assertSame(10, $user->version());
}
public function test_upload_avatar_without_crop_sends_only_the_picture(): void
{
$path = tempnam(sys_get_temp_dir(), 'avatar');
file_put_contents($path, 'picture-bytes');
$this->respond(200, $this->profile());
try {
$this->owner()->uploadAvatar(new \SplFileInfo($path));
} finally {
unlink($path);
}
$body = (string) $this->sent[0]['request']->getBody();
$this->assertStringContainsString('name="avatar"', $body);
$this->assertStringNotContainsString('crop', $body);
}
public function test_a_refused_upload_carries_its_code(): void
{
$path = tempnam(sys_get_temp_dir(), 'avatar');
file_put_contents($path, 'not a picture');
$this->respond(422, $this->validationError(['avatar' => 'avatar_invalid']));
$this->respond(413, ['message' => 'The picture is too large.', 'error' => 'avatar_too_large']);
try {
try {
$this->owner()->uploadAvatar($path);
$this->fail('No exception thrown');
} catch (ValidationFailedException $e) {
$this->assertSame('avatar_invalid', $e->errorCodeFor('avatar'));
}
try {
$this->owner()->uploadAvatar($path);
$this->fail('No exception thrown');
} catch (ApiException $e) {
$this->assertSame(413, $e->status);
$this->assertSame('avatar_too_large', $e->error);
}
} finally {
unlink($path);
}
}
public function test_crop_codes_are_found_under_the_crop_field(): void
{
$path = tempnam(sys_get_temp_dir(), 'avatar');
file_put_contents($path, 'a picture');
$this->respond(422, $this->validationError(['crop.size' => 'crop_invalid']));
try {
$this->owner()->uploadAvatar($path, ['x' => 0, 'y' => 0]);
$this->fail('No exception thrown');
} catch (ValidationFailedException $e) {
$this->assertSame('crop_invalid', $e->errorCodeFor('crop'));
$this->assertSame('crop_invalid', $e->errorCodeFor('crop.size'));
$this->assertNull($e->errorCodeFor('cro'));
$this->assertNull($e->errorCodeFor('avatar'));
} finally {
unlink($path);
}
}
public function test_an_unreadable_file_throws_before_anything_is_sent(): void
{
try {
$this->owner()->uploadAvatar(sys_get_temp_dir() . '/missing-avatar-' . uniqid() . '.png');
$this->fail('No exception thrown');
} catch (\RuntimeException $e) {
$this->assertNotInstanceOf(ApiException::class, $e);
$this->assertCount(0, $this->sent);
}
}
public function test_remove_avatar_deletes_the_upload_and_clears_the_cache(): void
{
$owner = $this->owner();
$this->respond(200, $this->profile());
$this->respond(200, $this->profile(['profile_version' => 11]));
$this->respond(200, $this->profile(['profile_version' => 11]));
$owner->getUserData();
$user = $owner->removeAvatar();
$owner->getUserData();
$this->assertSame('DELETE /api/v1/user/profile/avatar Bearer user-token', $this->sentCall(1));
$this->assertSame(11, $user->version());
$this->assertCount(3, $this->sent);
}
}
+61
View File
@@ -0,0 +1,61 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable;
use Anikeen\Id\Exceptions\PreconditionFailedException;
use Anikeen\Id\Exceptions\ValidationFailedException;
use Illuminate\Database\Eloquent\Model;
class ValidationFailedTest extends TestCase
{
private function owner(): Model
{
$owner = new class extends Model {
use Billable;
protected $guarded = [];
};
$owner->anikeen_id = '01JUSER';
$owner->anikeen_id_access_token = 'user-token';
return $owner;
}
public function test_422_of_app_requests_is_a_validation_exception(): void
{
$this->respond(422, ['message' => 'The value is too large.', 'error' => 'value_too_large']);
try {
app(AnikeenId::class)->setPreferenceByUserId('01JUSER', 'example', 'theme', str_repeat('x', 10));
$this->fail('No exception thrown');
} catch (ValidationFailedException $e) {
$this->assertSame('value_too_large', $e->error);
$this->assertSame([], $e->errorCodes());
$this->assertTrue($e->hasErrorCode('value_too_large'));
}
}
public function test_412_stays_a_precondition_failure(): void
{
$this->respond(412, ['message' => 'Version mismatch.', 'error' => 'preference_version_mismatch', 'current_version' => 3]);
$this->expectException(PreconditionFailedException::class);
$this->owner()->setGlobalPreference('appearance', 'dark', ifVersion: 2);
}
public function test_422_of_user_requests_is_a_validation_exception(): void
{
$this->respond(422, ['message' => 'Unknown value.', 'error' => 'invalid_shared_value']);
try {
$this->owner()->setGlobalPreference('appearance', 'purple');
$this->fail('No exception thrown');
} catch (ValidationFailedException $e) {
$this->assertSame(422, $e->status);
$this->assertSame('invalid_shared_value', $e->error);
$this->assertSame([], $e->errorCodes());
}
}
}
+10
View File
@@ -84,6 +84,16 @@ class WebhookTest extends TestCase
$this->assertContains(EventType::BILLING_LIMIT_AVAILABLE, EventType::all()); $this->assertContains(EventType::BILLING_LIMIT_AVAILABLE, EventType::all());
} }
public function test_user_and_preference_events_have_their_own_types(): void
{
$this->assertSame('preference.updated', EventType::PREFERENCE_UPDATED);
$this->assertSame('user.updated', EventType::USER_UPDATED);
$this->assertSame('user.deleted', EventType::USER_DELETED);
$this->assertContains(EventType::PREFERENCE_UPDATED, EventType::all());
$this->assertContains(EventType::USER_UPDATED, EventType::all());
$this->assertContains(EventType::USER_DELETED, EventType::all());
}
public function test_a_change_carries_the_previous_attributes(): void public function test_a_change_carries_the_previous_attributes(): void
{ {
$this->payload = json_encode(['id' => '01JEVT', 'type' => EventType::SUBSCRIPTION_UPDATED, 'created' => time(), 'data' => [ $this->payload = json_encode(['id' => '01JEVT', 'type' => EventType::SUBSCRIPTION_UPDATED, 'created' => time(), 'data' => [