diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b351e0..40dd551 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,26 @@ # Changelog +## Unreleased + +Preferences; they need an Anikeen ID release that supports them. + +### Added + +- Preferences of any user with the app's token (trusted apps, registered by Anikeen per + namespace): `preferencesByUserId()`, `preferenceByUserId()`, `setPreferenceByUserId()`, + `deletePreferenceByUserId()` and `findUserIdByPreference()` (the user holding a value of a unique + key, `null` when nobody does). +- The user's shared preferences with their token: `globalPreferences()`, `globalPreference()`, + `setGlobalPreference()` and `deleteGlobalPreference()` on `Billable`, cached per instance. +- `Resources\Preference` and `Resources\Preferences`; `Enums\SharedPreference` and + `Enums\Appearance`. +- Conditional writes: `ifVersion` (`If-Match`) and `createOnly` (`If-None-Match: *`); + `PreconditionFailedException` (a subclass of `ApiException`) for 412 with `currentVersion()`. +- Scopes `Scope::PREFERENCES`, `PREFERENCES_READ` and `PREFERENCES_CLIENT`. +- Webhook event `order.refunded` (`EventType::ORDER_REFUNDED`). +- An optional trailing `$headers` argument on `AnikeenId::request()`, `get()`, `post()`, `put()`, + `delete()` and `AppRequest::send()`; calls without it send the same request as before. + ## 3.3.0 (2026-11-06) App billing features; they need an Anikeen ID release that supports them. diff --git a/README.md b/README.md index 6dd2be9..6906a7e 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,12 @@ subscriptions and physical goods, with invoices, VAT and payment handled by Anik 3. [Sign in with Anikeen ID](#sign-in-with-anikeen-id) 4. [Payments](#payments) 5. [Trusted apps](#trusted-apps) -6. [API authentication](#api-authentication) -7. [General](#general) -8. [Examples](#examples) -9. [Documentation](#documentation) -10. [Development](#development) +6. [Preferences](#preferences) +7. [API authentication](#api-authentication) +8. [General](#general) +9. [Examples](#examples) +10. [Documentation](#documentation) +11. [Development](#development) ## Installation @@ -181,6 +182,14 @@ 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`; changing products needs `user`. +Anikeen ID's [preferences](#preferences) use these scopes: + +| Scope | Constant | Grants | +|-------|----------|--------| +| `preferences:read` | `Scope::PREFERENCES_READ` | read the user's shared preferences (`global`) | +| `preferences` | `Scope::PREFERENCES` | read and change them | +| `preferences:client` | `Scope::PREFERENCES_CLIENT` | app tokens: the preferences of any user, in the namespaces Anikeen registered for the app | + ### 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: @@ -549,6 +558,11 @@ if ($event['type'] === EventType::SUBSCRIPTION_RENEWED) { `order.*` events list the order's `items` and `invoice` like [app orders](#app-orders-trusted-apps-no-user-token). +`order.refunded` (`EventType::ORDER_REFUNDED`) is sent when money was given back for an order (a +refund or a partial cancellation, with a credit note); the order carries its `refunds` and +`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. + ### Errors Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as @@ -918,6 +932,156 @@ $result->data->invitation_sent; // only for an account created by this ca other than an unknown address. +## Preferences + +Anikeen ID keeps small per-user settings as JSON values under a *namespace* and a *key*, so they +follow the user across devices and apps: + +- **`global`** is shared by all apps. Only registered keys and values are accepted: today + `SharedPreference::APPEARANCE` with `Appearance::SYSTEM`, `LIGHT` or `DARK`. +- **App namespaces** (e.g. `acme`) hold one app's own data: onboarding state, a default workspace, + the user's customer ID at a payment provider. Use another namespace on staging (e.g. + `acme-staging`), so test data never mixes with production. + +**Registration.** Access never comes from scopes alone: any client can request any scope. Ask +Anikeen to register your OAuth client(s) (the app has to be [trusted](#trusted-apps)) for + +- the namespaces your app owns (read, write and the lookup), +- read access to another app's namespace, +- `global` with the app's token (users always read and write their own `global` with their token), +- keys whose values are unique (see the lookup below). + +Without it, app requests fail with 403 `client_not_trusted` or 404 `namespace_not_found`. + +**Scopes.** User tokens need `Scope::PREFERENCES` (`preferences`, read and write) or +`Scope::PREFERENCES_READ` (`preferences:read`); app tokens `Scope::PREFERENCES_CLIENT` +(`preferences:client`). + +**Limits.** A value is at most 2048 bytes of JSON and 4 levels deep; at most 50 keys per user and +namespace (updating a key always works); keys match `^[a-z][a-z0-9_]{0,63}$`. Per minute: 60 +requests per user and app with user tokens, 600 per app with the app token and 120 lookups per +app (429 `rate_limited` with `Retry-After`). + +**Not a secrets store.** Values are stored as plain JSON and shown to Anikeen's support and to the +user on a request for their data. Store IDs and settings, never passwords, API keys or tokens: keys +that look like credentials (`secret`, `token`, `password`, `api_key`, …) are rejected with 422 +`forbidden_key`. + +### The user's shared preferences (user token) + +```php +use Anikeen\Id\Enums\Appearance; +use Anikeen\Id\Enums\SharedPreference; + +$user->globalPreference(SharedPreference::APPEARANCE, Appearance::SYSTEM); // the default when not set +$user->globalPreferences()->values(); // ['appearance' => 'dark'] + +$preference = $user->setGlobalPreference(SharedPreference::APPEARANCE, Appearance::DARK); +$preference->version; // e.g. 1042 + +$user->deleteGlobalPreference(SharedPreference::APPEARANCE); +``` + +User tokens reach `global` only; an app namespace answers 404 `namespace_not_found`, whatever the +token's scopes. The app's own token (client credentials) has no user and gets 401 on these routes. + +### Preferences of any user (trusted apps) + +With the app's own token, by the user's Anikeen ID: + +```php +$anikeenId = app(AnikeenId::class); + +$preferences = $anikeenId->preferencesByUserId($userId, 'acme'); // sorted by key, no pagination +$preferences->get('onboarding', []); // the value or a default; also has(), find(), values() + +$preference = $anikeenId->preferenceByUserId($userId, 'acme', 'onboarding'); // 404 preference_not_found if not set +$preference->value; // JSON objects as stdClass, so {} stays {} +$preference->version; +$preference->updated_by; // {type: "user"|"app", client_id} + +$anikeenId->setPreferenceByUserId($userId, 'acme', 'onboarding', ['step' => 3]); +$anikeenId->deletePreferenceByUserId($userId, 'acme', 'onboarding'); +``` + +A value is any JSON value; pass `(object) []` for an empty object (`[]` is an empty list). +`setPreferenceByUserId()` returns the stored preference; `wasCreated()` tells whether the write +created the key. A deleted user's preferences are gone: every call answers 404 `user_not_found`. + +### Conditional writes and retries + +Writes are *last write wins* by default. Every write gives the preference a new `version`. +Versions are opaque: they only go up (also when a key is deleted and set again) and increase per +user, not by one per key. Pass the version you read to write only if nobody changed it in between +(`If-Match`), or `createOnly` to write only if the key isn't set yet (`If-None-Match: *`): + +```php +use Anikeen\Id\Exceptions\PreconditionFailedException; + +$preference = $anikeenId->preferenceByUserId($userId, 'acme', 'layout'); + +try { + $anikeenId->setPreferenceByUserId($userId, 'acme', 'layout', $merged, ifVersion: $preference->version); +} catch (PreconditionFailedException $e) { + $e->error; // "preference_version_mismatch" (or "preference_exists" for createOnly) + $e->currentVersion(); // the version stored now, null when the key isn't set: reload, merge, retry +} + +$anikeenId->setPreferenceByUserId($userId, 'acme', 'welcome_sent_at', now()->toIso8601String(), createOnly: true); +``` + +`deletePreferenceByUserId()`, `setGlobalPreference()` and `deleteGlobalPreference()` take +`ifVersion` too. Writing the value that is already stored is a no-op that succeeds (no new version), +even when the precondition would fail, so retrying a write after a timeout is safe. + +### Unique keys and the lookup + +Anikeen can declare keys of your namespace unique, e.g. `customer_id`. Their values are strings of +1 to 255 characters without surrounding whitespace, held by at most one user: setting another +user's value fails with 409 `preference_value_taken`. Your app can then find the user by value, e.g. +in a payment provider's webhook: + +```php +$userId = $anikeenId->findUserIdByPreference('acme', 'customer_id', 'cus_123'); // null if nobody holds it +``` + +The value is sent in the body, never in the URL. `findUserIdByPreference()` returns `null` only for +404 `preference_not_found` (also when the user was deleted); everything else throws, e.g. 422 +`key_not_unique` for a key that isn't declared unique. + +### Error codes + +Failures throw `ApiException` (412 `PreconditionFailedException`) with these `error` codes: + +| Status | `error` | +|--------|---------| +| 401 | `unauthenticated` (no or an invalid token; an app token on the user routes) | +| 403 | `missing_scope`, `app_token_required` (a user token on the app routes), `client_not_trusted`, `namespace_read_only`, `lookup_not_allowed` | +| 404 | `namespace_not_found` (unknown or not accessible), `user_not_found` (also deleted users), `preference_not_found`, `not_found` | +| 405 | `method_not_allowed` | +| 409 | `preference_value_taken` | +| 412 | `preference_version_mismatch`, `preference_exists` | +| 422 | `invalid_key`, `forbidden_key`, `unknown_shared_key`, `value_required`, `value_too_large`, `value_too_deep`, `invalid_shared_value`, `invalid_unique_value`, `key_not_unique`, `preference_limit_exceeded` | +| 429 | `rate_limited` | + +A `null` value is rejected with 422 `value_required`: delete the key instead. + +### Caching + +Anikeen ID is the source of truth. `globalPreferences()` and `globalPreference()` cache the list +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 +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: + +```php +$appearance = Cache::remember("appearance:{$user->id}", now()->addHour(), + fn () => $user->globalPreference(SharedPreference::APPEARANCE, Appearance::SYSTEM)); + +$user->setGlobalPreference(SharedPreference::APPEARANCE, $request->appearance); +Cache::put("appearance:{$user->id}", $request->appearance, now()->addHour()); +``` + ## API authentication Use this when your own API accepts access tokens issued by Anikeen ID. @@ -1084,6 +1248,16 @@ AnikeenId::withToken('abcdef123456')->getAuthedUser(); public function retrievingToken(string $grantType, array $attributes): Result ``` +### ManagesPreferences + +```php +public function preferencesByUserId(string $userId, string $namespace): Preferences +public function preferenceByUserId(string $userId, string $namespace, string $key): Preference +public function setPreferenceByUserId(string $userId, string $namespace, string $key, mixed $value, ?int $ifVersion = null, bool $createOnly = false): Preference +public function deletePreferenceByUserId(string $userId, string $namespace, string $key, ?int $ifVersion = null): void +public function findUserIdByPreference(string $namespace, string $key, string $value): ?string +``` + ### ManagesPricing ```php @@ -1241,6 +1415,15 @@ public function setBillable(Billable|Model $billable): self public function getBillable(): Billable ``` +### ManagesUserPreferences + +```php +public function globalPreferences(): Preferences +public function globalPreference(string $key, mixed $default = null): mixed +public function setGlobalPreference(string $key, mixed $value, ?int $ifVersion = null): Preference +public function deleteGlobalPreference(string $key, ?int $ifVersion = null): void +``` + ### ManagesUserSshKeys ```php diff --git a/README.stub b/README.stub index 1bf43ae..51e4979 100644 --- a/README.stub +++ b/README.stub @@ -16,11 +16,12 @@ subscriptions and physical goods, with invoices, VAT and payment handled by Anik 3. [Sign in with Anikeen ID](#sign-in-with-anikeen-id) 4. [Payments](#payments) 5. [Trusted apps](#trusted-apps) -6. [API authentication](#api-authentication) -7. [General](#general) -8. [Examples](#examples) -9. [Documentation](#documentation) -10. [Development](#development) +6. [Preferences](#preferences) +7. [API authentication](#api-authentication) +8. [General](#general) +9. [Examples](#examples) +10. [Documentation](#documentation) +11. [Development](#development) ## Installation @@ -181,6 +182,14 @@ 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`; changing products needs `user`. +Anikeen ID's [preferences](#preferences) use these scopes: + +| Scope | Constant | Grants | +|-------|----------|--------| +| `preferences:read` | `Scope::PREFERENCES_READ` | read the user's shared preferences (`global`) | +| `preferences` | `Scope::PREFERENCES` | read and change them | +| `preferences:client` | `Scope::PREFERENCES_CLIENT` | app tokens: the preferences of any user, in the namespaces Anikeen registered for the app | + ### 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: @@ -549,6 +558,11 @@ if ($event['type'] === EventType::SUBSCRIPTION_RENEWED) { `order.*` events list the order's `items` and `invoice` like [app orders](#app-orders-trusted-apps-no-user-token). +`order.refunded` (`EventType::ORDER_REFUNDED`) is sent when money was given back for an order (a +refund or a partial cancellation, with a credit note); the order carries its `refunds` and +`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. + ### Errors Every failed request throws `Anikeen\Id\Exceptions\ApiException`, with the user's token as @@ -918,6 +932,156 @@ $result->data->invitation_sent; // only for an account created by this ca other than an unknown address. +## Preferences + +Anikeen ID keeps small per-user settings as JSON values under a *namespace* and a *key*, so they +follow the user across devices and apps: + +- **`global`** is shared by all apps. Only registered keys and values are accepted: today + `SharedPreference::APPEARANCE` with `Appearance::SYSTEM`, `LIGHT` or `DARK`. +- **App namespaces** (e.g. `acme`) hold one app's own data: onboarding state, a default workspace, + the user's customer ID at a payment provider. Use another namespace on staging (e.g. + `acme-staging`), so test data never mixes with production. + +**Registration.** Access never comes from scopes alone: any client can request any scope. Ask +Anikeen to register your OAuth client(s) (the app has to be [trusted](#trusted-apps)) for + +- the namespaces your app owns (read, write and the lookup), +- read access to another app's namespace, +- `global` with the app's token (users always read and write their own `global` with their token), +- keys whose values are unique (see the lookup below). + +Without it, app requests fail with 403 `client_not_trusted` or 404 `namespace_not_found`. + +**Scopes.** User tokens need `Scope::PREFERENCES` (`preferences`, read and write) or +`Scope::PREFERENCES_READ` (`preferences:read`); app tokens `Scope::PREFERENCES_CLIENT` +(`preferences:client`). + +**Limits.** A value is at most 2048 bytes of JSON and 4 levels deep; at most 50 keys per user and +namespace (updating a key always works); keys match `^[a-z][a-z0-9_]{0,63}$`. Per minute: 60 +requests per user and app with user tokens, 600 per app with the app token and 120 lookups per +app (429 `rate_limited` with `Retry-After`). + +**Not a secrets store.** Values are stored as plain JSON and shown to Anikeen's support and to the +user on a request for their data. Store IDs and settings, never passwords, API keys or tokens: keys +that look like credentials (`secret`, `token`, `password`, `api_key`, …) are rejected with 422 +`forbidden_key`. + +### The user's shared preferences (user token) + +```php +use Anikeen\Id\Enums\Appearance; +use Anikeen\Id\Enums\SharedPreference; + +$user->globalPreference(SharedPreference::APPEARANCE, Appearance::SYSTEM); // the default when not set +$user->globalPreferences()->values(); // ['appearance' => 'dark'] + +$preference = $user->setGlobalPreference(SharedPreference::APPEARANCE, Appearance::DARK); +$preference->version; // e.g. 1042 + +$user->deleteGlobalPreference(SharedPreference::APPEARANCE); +``` + +User tokens reach `global` only; an app namespace answers 404 `namespace_not_found`, whatever the +token's scopes. The app's own token (client credentials) has no user and gets 401 on these routes. + +### Preferences of any user (trusted apps) + +With the app's own token, by the user's Anikeen ID: + +```php +$anikeenId = app(AnikeenId::class); + +$preferences = $anikeenId->preferencesByUserId($userId, 'acme'); // sorted by key, no pagination +$preferences->get('onboarding', []); // the value or a default; also has(), find(), values() + +$preference = $anikeenId->preferenceByUserId($userId, 'acme', 'onboarding'); // 404 preference_not_found if not set +$preference->value; // JSON objects as stdClass, so {} stays {} +$preference->version; +$preference->updated_by; // {type: "user"|"app", client_id} + +$anikeenId->setPreferenceByUserId($userId, 'acme', 'onboarding', ['step' => 3]); +$anikeenId->deletePreferenceByUserId($userId, 'acme', 'onboarding'); +``` + +A value is any JSON value; pass `(object) []` for an empty object (`[]` is an empty list). +`setPreferenceByUserId()` returns the stored preference; `wasCreated()` tells whether the write +created the key. A deleted user's preferences are gone: every call answers 404 `user_not_found`. + +### Conditional writes and retries + +Writes are *last write wins* by default. Every write gives the preference a new `version`. +Versions are opaque: they only go up (also when a key is deleted and set again) and increase per +user, not by one per key. Pass the version you read to write only if nobody changed it in between +(`If-Match`), or `createOnly` to write only if the key isn't set yet (`If-None-Match: *`): + +```php +use Anikeen\Id\Exceptions\PreconditionFailedException; + +$preference = $anikeenId->preferenceByUserId($userId, 'acme', 'layout'); + +try { + $anikeenId->setPreferenceByUserId($userId, 'acme', 'layout', $merged, ifVersion: $preference->version); +} catch (PreconditionFailedException $e) { + $e->error; // "preference_version_mismatch" (or "preference_exists" for createOnly) + $e->currentVersion(); // the version stored now, null when the key isn't set: reload, merge, retry +} + +$anikeenId->setPreferenceByUserId($userId, 'acme', 'welcome_sent_at', now()->toIso8601String(), createOnly: true); +``` + +`deletePreferenceByUserId()`, `setGlobalPreference()` and `deleteGlobalPreference()` take +`ifVersion` too. Writing the value that is already stored is a no-op that succeeds (no new version), +even when the precondition would fail, so retrying a write after a timeout is safe. + +### Unique keys and the lookup + +Anikeen can declare keys of your namespace unique, e.g. `customer_id`. Their values are strings of +1 to 255 characters without surrounding whitespace, held by at most one user: setting another +user's value fails with 409 `preference_value_taken`. Your app can then find the user by value, e.g. +in a payment provider's webhook: + +```php +$userId = $anikeenId->findUserIdByPreference('acme', 'customer_id', 'cus_123'); // null if nobody holds it +``` + +The value is sent in the body, never in the URL. `findUserIdByPreference()` returns `null` only for +404 `preference_not_found` (also when the user was deleted); everything else throws, e.g. 422 +`key_not_unique` for a key that isn't declared unique. + +### Error codes + +Failures throw `ApiException` (412 `PreconditionFailedException`) with these `error` codes: + +| Status | `error` | +|--------|---------| +| 401 | `unauthenticated` (no or an invalid token; an app token on the user routes) | +| 403 | `missing_scope`, `app_token_required` (a user token on the app routes), `client_not_trusted`, `namespace_read_only`, `lookup_not_allowed` | +| 404 | `namespace_not_found` (unknown or not accessible), `user_not_found` (also deleted users), `preference_not_found`, `not_found` | +| 405 | `method_not_allowed` | +| 409 | `preference_value_taken` | +| 412 | `preference_version_mismatch`, `preference_exists` | +| 422 | `invalid_key`, `forbidden_key`, `unknown_shared_key`, `value_required`, `value_too_large`, `value_too_deep`, `invalid_shared_value`, `invalid_unique_value`, `key_not_unique`, `preference_limit_exceeded` | +| 429 | `rate_limited` | + +A `null` value is rejected with 422 `value_required`: delete the key instead. + +### Caching + +Anikeen ID is the source of truth. `globalPreferences()` and `globalPreference()` cache the list +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 +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: + +```php +$appearance = Cache::remember("appearance:{$user->id}", now()->addHour(), + fn () => $user->globalPreference(SharedPreference::APPEARANCE, Appearance::SYSTEM)); + +$user->setGlobalPreference(SharedPreference::APPEARANCE, $request->appearance); +Cache::put("appearance:{$user->id}", $request->appearance, now()->addHour()); +``` + ## API authentication Use this when your own API accepts access tokens issued by Anikeen ID. diff --git a/src/Id/AnikeenId.php b/src/Id/AnikeenId.php index 93f504b..c1e2b77 100644 --- a/src/Id/AnikeenId.php +++ b/src/Id/AnikeenId.php @@ -2,6 +2,7 @@ namespace Anikeen\Id; +use Anikeen\Id\Concerns\ManagesPreferences; use Anikeen\Id\Concerns\ManagesPricing; use Anikeen\Id\Concerns\ManagesProducts; use Anikeen\Id\Concerns\ManagesShippingLabels; @@ -23,6 +24,7 @@ use Illuminate\Contracts\Auth\Authenticatable; class AnikeenId { use OauthTrait; + use ManagesPreferences; use ManagesPricing; use ManagesProducts; use ManagesShippingLabels; @@ -369,10 +371,12 @@ class AnikeenId /** * Build query & execute. * + * @param array $headers Extra request headers, e.g. `If-Match` for conditional + * writes; they are sent in addition to (and may not replace) Client-ID, Accept and Authorization. * @throws GuzzleException * @throws RequestRequiresClientIdException */ - public function request(string $method, string $path, null|array $payload = null, array $parameters = [], ?Paginator $paginator = null, bool $useClientSecret = false): Result + public function request(string $method, string $path, null|array $payload = null, array $parameters = [], ?Paginator $paginator = null, bool $useClientSecret = false, array $headers = []): Result { if ($paginator !== null) { $parameters[$paginator->action] = $paginator->cursor(); @@ -380,7 +384,7 @@ class AnikeenId try { $response = $this->client->request($method, $path, [ - 'headers' => $this->buildHeaders((bool)$payload, $useClientSecret), + 'headers' => $this->buildHeaders((bool)$payload, $useClientSecret) + $headers, 'query' => Query::build($parameters), 'json' => $payload ?: null, ]); @@ -419,35 +423,35 @@ class AnikeenId * @throws GuzzleException * @throws RequestRequiresClientIdException */ - public function get(string $path, array $parameters = [], ?Paginator $paginator = null): Result + public function get(string $path, array $parameters = [], ?Paginator $paginator = null, array $headers = []): Result { - return $this->request('GET', $path, null, $parameters, $paginator); + return $this->request('GET', $path, null, $parameters, $paginator, false, $headers); } /** * @throws GuzzleException * @throws RequestRequiresClientIdException */ - public function post(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result + public function post(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null, array $headers = []): Result { - return $this->request('POST', $path, $payload, $parameters, $paginator); + return $this->request('POST', $path, $payload, $parameters, $paginator, false, $headers); } /** * @throws GuzzleException * @throws RequestRequiresClientIdException */ - public function put(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result + public function put(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null, array $headers = []): Result { - return $this->request('PUT', $path, $payload, $parameters, $paginator); + return $this->request('PUT', $path, $payload, $parameters, $paginator, false, $headers); } /** * @throws GuzzleException * @throws RequestRequiresClientIdException */ - public function delete(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null): Result + public function delete(string $path, array $payload = [], array $parameters = [], ?Paginator $paginator = null, array $headers = []): Result { - return $this->request('DELETE', $path, $payload, $parameters, $paginator); + return $this->request('DELETE', $path, $payload, $parameters, $paginator, false, $headers); } } diff --git a/src/Id/Billable.php b/src/Id/Billable.php index 511d9e9..d2c64d1 100644 --- a/src/Id/Billable.php +++ b/src/Id/Billable.php @@ -9,6 +9,7 @@ use Anikeen\Id\Concerns\ManagesInvoices; use Anikeen\Id\Concerns\ManagesOrders; use Anikeen\Id\Concerns\ManagesPaymentMethods; use Anikeen\Id\Concerns\ManagesProfile; +use Anikeen\Id\Concerns\ManagesUserPreferences; use Anikeen\Id\Concerns\ManagesSubscriptions; use Anikeen\Id\Concerns\ManagesTaxation; use Anikeen\Id\Concerns\ManagesTransactions; @@ -27,6 +28,7 @@ trait Billable use ManagesSubscriptions; use ManagesTaxation; use ManagesTransactions; + use ManagesUserPreferences; use ManagesUserSshKeys; /* @@ -40,6 +42,8 @@ trait Billable protected mixed $invoicesCache = null; protected mixed $ordersCache = null; protected mixed $paymentMethodsCache = null; + /** @var array by namespace */ + protected array $preferencesCache = []; protected mixed $subscriptionsCache = null; protected mixed $transactionsCache = null; protected mixed $userDataCache = null; diff --git a/src/Id/Concerns/ManagesPreferences.php b/src/Id/Concerns/ManagesPreferences.php new file mode 100644 index 0000000..ee703c4 --- /dev/null +++ b/src/Id/Concerns/ManagesPreferences.php @@ -0,0 +1,107 @@ + AppRequest::send('GET', $this->userPreferencePath($userId, $namespace))); + } + + /** + * One preference of a user. + * + * @throws ApiException 404 "preference_not_found" when the key isn't set, "user_not_found", … + */ + public function preferenceByUserId(string $userId, string $namespace, string $key): Preference + { + return new Preference(fn () => AppRequest::send('GET', $this->userPreferencePath($userId, $namespace, $key))); + } + + /** + * Set a preference of a user. The value is any JSON value (pass `(object) []` for `{}`), at + * most 2048 bytes and 4 levels deep. Writing the value already stored changes nothing and + * succeeds, also when a precondition would fail, so a retried write is safe. + * + * @param int|null $ifVersion only if the stored version is this one (`If-Match`) + * @param bool $createOnly only if the key isn't set yet (`If-None-Match: *`); not with $ifVersion + * @throws PreconditionFailedException 412 "preference_version_mismatch" / "preference_exists", + * with currentVersion() + * @throws ApiException 403 "namespace_read_only", 409 "preference_value_taken" (a unique key), + * 422 "invalid_key", "forbidden_key", "value_too_large", "preference_limit_exceeded", … + */ + public function setPreferenceByUserId(string $userId, string $namespace, string $key, mixed $value, ?int $ifVersion = null, bool $createOnly = false): Preference + { + $headers = PreferenceRequest::conditions($ifVersion, $createOnly); + + return new Preference(fn () => AppRequest::send('PUT', $this->userPreferencePath($userId, $namespace, $key), ['value' => $value], [], $headers)); + } + + /** + * Delete a preference of a user. + * + * @param int|null $ifVersion only if the stored version is this one (`If-Match`) + * @throws PreconditionFailedException 412 "preference_version_mismatch", with currentVersion() + * @throws ApiException 404 "preference_not_found" when the key isn't set, … + */ + public function deletePreferenceByUserId(string $userId, string $namespace, string $key, ?int $ifVersion = null): void + { + AppRequest::send('DELETE', $this->userPreferencePath($userId, $namespace, $key), null, [], PreferenceRequest::conditions($ifVersion)); + } + + /** + * The ID of the user holding a value of a unique key in one of the app's own namespaces, e.g. + * the user of a payment provider's customer ID. The value is sent in the body, never in the URL. + * + * @return string|null null when no (not deleted) user holds the value + * @throws ApiException 422 "key_not_unique", "invalid_unique_value", 403 "lookup_not_allowed", … + */ + public function findUserIdByPreference(string $namespace, string $key, string $value): ?string + { + try { + $result = AppRequest::send('POST', PreferenceRequest::path('v1/client/preferences', $namespace, 'lookup'), [ + 'key' => $key, + 'value' => $value, + ]); + } catch (ApiException $e) { + // only Anikeen ID's own answer means "nobody holds it", not e.g. an unknown namespace + if ($e->isNotFound() && $e->error === 'preference_not_found') { + return null; + } + + throw $e; + } + + return $result->data->user_id; + } + + private function userPreferencePath(string $userId, string $namespace, ?string $key = null): string + { + $segments = [$userId, 'preferences', $namespace]; + + if ($key !== null) { + $segments[] = $key; + } + + return PreferenceRequest::path('v1/client/users', ...$segments); + } +} diff --git a/src/Id/Concerns/ManagesUserPreferences.php b/src/Id/Concerns/ManagesUserPreferences.php new file mode 100644 index 0000000..be02647 --- /dev/null +++ b/src/Id/Concerns/ManagesUserPreferences.php @@ -0,0 +1,96 @@ +preferencesCache[$namespace])) { + $anikeenId = $this->anikeenId(); + + $this->preferencesCache[$namespace] = Preferences::builder( + fn () => $anikeenId->get(PreferenceRequest::path('v1/preferences', $namespace)) + ); + } + + return $this->preferencesCache[$namespace]; + } + + /** + * The value of a shared preference, or $default when the user hasn't set it. + * + * @throws ApiException + */ + public function globalPreference(string $key, mixed $default = null): mixed + { + return $this->globalPreferences()->get($key, $default); + } + + /** + * Set a shared preference of the user, e.g. setGlobalPreference(SharedPreference::APPEARANCE, + * Appearance::DARK). Only registered keys and values are accepted (422 "unknown_shared_key", + * "invalid_shared_value"). + * + * @param int|null $ifVersion only if the stored version is this one (`If-Match`) + * @throws PreconditionFailedException 412 "preference_version_mismatch", with currentVersion() + * @throws ApiException + */ + public function setGlobalPreference(string $key, mixed $value, ?int $ifVersion = null): Preference + { + $namespace = SharedPreference::NAMESPACE; + $headers = PreferenceRequest::conditions($ifVersion); + $anikeenId = $this->anikeenId(); + + try { + return new Preference(fn () => $anikeenId->put(PreferenceRequest::path('v1/preferences', $namespace, $key), ['value' => $value], [], null, $headers)); + } finally { + unset($this->preferencesCache[$namespace]); + } + } + + /** + * Delete a shared preference of the user. + * + * @param int|null $ifVersion only if the stored version is this one (`If-Match`) + * @throws PreconditionFailedException 412 "preference_version_mismatch", with currentVersion() + * @throws ApiException 404 "preference_not_found" when the key isn't set, … + */ + public function deleteGlobalPreference(string $key, ?int $ifVersion = null): void + { + $namespace = SharedPreference::NAMESPACE; + $headers = PreferenceRequest::conditions($ifVersion); + + try { + $result = $this->anikeenId()->delete(PreferenceRequest::path('v1/preferences', $namespace, $key), [], [], null, $headers); + } catch (GuzzleException $e) { + throw ApiException::unreachable($e); + } finally { + unset($this->preferencesCache[$namespace]); + } + + if (!$result->success()) { + throw ApiException::fromResult($result); + } + } +} diff --git a/src/Id/Enums/Appearance.php b/src/Id/Enums/Appearance.php new file mode 100644 index 0000000..6106a02 --- /dev/null +++ b/src/Id/Enums/Appearance.php @@ -0,0 +1,22 @@ + + */ + public static function all(): array + { + return [self::SYSTEM, self::LIGHT, self::DARK]; + } +} diff --git a/src/Id/Enums/Scope.php b/src/Id/Enums/Scope.php index 0ce17a2..1a2c35a 100644 --- a/src/Id/Enums/Scope.php +++ b/src/Id/Enums/Scope.php @@ -33,6 +33,12 @@ class Scope const TRANSACTIONS_READ = 'transactions:read'; const TRANSACTIONS_CLIENT = 'transactions:client'; + // The user's own shared preferences (`global`). PREFERENCES_CLIENT labels an app token for + // the preferences of any user; access to a namespace is granted by Anikeen, not by the scope. + const PREFERENCES = 'preferences'; + const PREFERENCES_READ = 'preferences:read'; + const PREFERENCES_CLIENT = 'preferences:client'; + const SSH_KEYS = 'ssh-keys'; const SSH_KEYS_READ = 'ssh-keys:read'; diff --git a/src/Id/Enums/SharedPreference.php b/src/Id/Enums/SharedPreference.php new file mode 100644 index 0000000..efab122 --- /dev/null +++ b/src/Id/Enums/SharedPreference.php @@ -0,0 +1,16 @@ +data) ? $result->data : null; + $status = $result->response()?->getStatusCode() ?? 0; + + // a failed conditional write; subclasses (CollectionException) keep their own type + if ($status === 412 && static::class === self::class) { + return PreconditionFailedException::fromResult($result); + } return new static( rtrim((string) ($body->message ?? $result->error()), '.'), - $result->response()?->getStatusCode() ?? 0, + $status, $body->error ?? null, $result->exception, ); diff --git a/src/Id/Exceptions/PreconditionFailedException.php b/src/Id/Exceptions/PreconditionFailedException.php new file mode 100644 index 0000000..99e86c1 --- /dev/null +++ b/src/Id/Exceptions/PreconditionFailedException.php @@ -0,0 +1,21 @@ +body()->current_version ?? null; + + return is_numeric($version) ? (int) $version : null; + } +} diff --git a/src/Id/Resources/Preference.php b/src/Id/Resources/Preference.php new file mode 100644 index 0000000..585a9a7 --- /dev/null +++ b/src/Id/Resources/Preference.php @@ -0,0 +1,27 @@ +result->response()?->getStatusCode() === 201; + } +} diff --git a/src/Id/Resources/Preferences.php b/src/Id/Resources/Preferences.php new file mode 100644 index 0000000..fbd5ed4 --- /dev/null +++ b/src/Id/Resources/Preferences.php @@ -0,0 +1,68 @@ +item($id); + + if ($data === null) { + return null; + } + + $result = clone $this->result; + $result->data = $data; + + return new Preference(fn () => $result); + } + + /** + * The value of a key, or $default when it isn't set. + */ + public function get(string $key, mixed $default = null): mixed + { + $data = $this->item($key); + + return $data !== null ? $data->value : $default; + } + + public function has(string $key): bool + { + return $this->item($key) !== null; + } + + /** + * The values by key. + * + * @return array + */ + public function values(): array + { + $values = []; + + foreach ((array) $this->result->data as $data) { + $values[$data->key] = $data->value; + } + + return $values; + } + + private function item(string $key): ?object + { + foreach ((array) $this->result->data as $data) { + if (($data->key ?? null) === $key) { + return $data; + } + } + + return null; + } +} diff --git a/src/Id/Support/AppRequest.php b/src/Id/Support/AppRequest.php index 9070d4f..4fa2240 100644 --- a/src/Id/Support/AppRequest.php +++ b/src/Id/Support/AppRequest.php @@ -5,36 +5,40 @@ namespace Anikeen\Id\Support; use Anikeen\Id\AnikeenId; use Anikeen\Id\Contracts\AppTokenRepository; use Anikeen\Id\Exceptions\ApiException; +use Anikeen\Id\Exceptions\PreconditionFailedException; use Anikeen\Id\Result; use GuzzleHttp\Exception\GuzzleException; use Throwable; /** * 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. + * once; every failure ends in an ApiException with status and error code (a 412 in a + * PreconditionFailedException). */ final class AppRequest { /** + * @param array $headers Extra request headers, e.g. `If-Match`. * @throws ApiException */ - public static function send(string $method, string $path, ?array $payload = null, array $query = []): Result + public static function send(string $method, string $path, ?array $payload = null, array $query = [], array $headers = []): Result { - $result = self::attempt($method, $path, $payload, $query); + $result = self::attempt($method, $path, $payload, $query, $headers); if ($result->status === 401) { $tokens = app(AppTokenRepository::class); if (method_exists($tokens, 'forgetAccessToken')) { $tokens->forgetAccessToken(); - $result = self::attempt($method, $path, $payload, $query); + $result = self::attempt($method, $path, $payload, $query, $headers); } } if (!$result->success()) { $body = is_object($result->data) ? $result->data : null; + $exception = $result->status === 412 ? PreconditionFailedException::class : ApiException::class; - throw new ApiException( + throw new $exception( sprintf('Anikeen ID request %s %s failed: %s', $method, $path, $body->message ?? $result->error()), $result->response()?->getStatusCode() ?? 0, $body->error ?? null, @@ -48,10 +52,10 @@ final class AppRequest /** * @throws ApiException */ - private static function attempt(string $method, string $path, ?array $payload, array $query): Result + private static function attempt(string $method, string $path, ?array $payload, array $query, array $headers = []): Result { try { - return app(AnikeenId::class)->asApp()->request($method, $path, $payload, $query); + return app(AnikeenId::class)->asApp()->request($method, $path, $payload, $query, null, false, $headers); } catch (ApiException $e) { throw $e; } catch (GuzzleException|Throwable $e) { diff --git a/src/Id/Support/PreferenceRequest.php b/src/Id/Support/PreferenceRequest.php new file mode 100644 index 0000000..3d80e1a --- /dev/null +++ b/src/Id/Support/PreferenceRequest.php @@ -0,0 +1,42 @@ +"` (only if the stored version is this one) or `If-None-Match: *` + * (only if the key isn't set yet). + * + * @return array + */ + public static function conditions(?int $ifVersion, bool $createOnly = false): array + { + if ($ifVersion !== null && $createOnly) { + throw new InvalidArgumentException('A write is either conditional on a version or create-only, not both.'); + } + + return match (true) { + $ifVersion !== null => ['If-Match' => sprintf('"%d"', $ifVersion)], + $createOnly => ['If-None-Match' => '*'], + default => [], + }; + } + + private function __construct() + { + } +} diff --git a/src/Id/Webhooks/EventType.php b/src/Id/Webhooks/EventType.php index 06d5f21..9a1de68 100644 --- a/src/Id/Webhooks/EventType.php +++ b/src/Id/Webhooks/EventType.php @@ -13,6 +13,12 @@ final class EventType public const ORDER_PAYMENT_FAILED = 'order.payment_failed'; public const ORDER_CANCELLED = 'order.cancelled'; public const ORDER_SHIPPED = 'order.shipped'; + /** + * Money was given back for an order (a refund or a partial cancellation, with a credit note); + * see the order's `refunds` and `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. + */ + public const ORDER_REFUNDED = 'order.refunded'; public const SUBSCRIPTION_CREATED = 'subscription.created'; public const SUBSCRIPTION_ACTIVATED = 'subscription.activated'; diff --git a/tests/Id/BillableModelTest.php b/tests/Id/BillableModelTest.php index c7d8c3a..540f6b6 100644 --- a/tests/Id/BillableModelTest.php +++ b/tests/Id/BillableModelTest.php @@ -19,6 +19,8 @@ class BillableModelTest extends TestCase foreach (['addresses', 'countries', 'defaultPaymentMethod', 'invoices', 'orders', 'paymentMethods', 'subscriptions', 'transactions', 'userData'] as $name) { $this->{$name.'Cache'} = 'cached'; } + + $this->preferencesCache['global'] = 'cached'; } }; $user->email = 'ada@example.com'; diff --git a/tests/Id/EndpointsTest.php b/tests/Id/EndpointsTest.php index 1fda366..a03313f 100644 --- a/tests/Id/EndpointsTest.php +++ b/tests/Id/EndpointsTest.php @@ -283,4 +283,133 @@ class EndpointsTest extends TestCase $this->assertSame(['items' => [], 'billing_address' => ['country_iso' => 'DE']], $this->sentJson(0)); } + + private function preference(string $namespace, string $key, mixed $value, int $version = 7): array + { + return ['namespace' => $namespace, 'key' => $key, 'value' => $value, 'version' => $version, 'updated_at' => '2026-11-06T10:00:00.000000Z', 'updated_by' => ['type' => 'app', 'client_id' => 'app-client']]; + } + + private function conditions(int $index): array + { + $request = $this->sent[$index]['request']; + + return [$request->getHeaderLine('If-Match'), $request->getHeaderLine('If-None-Match')]; + } + + public function test_global_preferences_of_the_user(): void + { + $this->respond(200, ['data' => [$this->preference('global', 'appearance', 'dark')]]); + + $preferences = $this->owner()->globalPreferences(); + + $this->assertSame('dark', $preferences->get('appearance')); + $this->assertSame('GET /api/v1/preferences/global Bearer user-token', $this->sentCall(0)); + } + + public function test_global_preference_with_a_default(): void + { + $this->respond(200, ['data' => [$this->preference('global', 'appearance', 'light')]]); + + $owner = $this->owner(); + + $this->assertSame('light', $owner->globalPreference('appearance')); + $this->assertSame('system', $owner->globalPreference('unknown', 'system')); + $this->assertCount(1, $this->sent); + $this->assertSame('GET /api/v1/preferences/global Bearer user-token', $this->sentCall(0)); + } + + public function test_set_global_preference(): void + { + $this->respond(201, ['data' => $this->preference('global', 'appearance', 'dark', 8)]); + $this->respond(200, ['data' => $this->preference('global', 'appearance', 'light', 9)]); + + $created = $this->owner()->setGlobalPreference('appearance', 'dark'); + $updated = $this->owner()->setGlobalPreference('appearance', 'light', ifVersion: 8); + + $this->assertTrue($created->wasCreated()); + $this->assertFalse($updated->wasCreated()); + $this->assertSame(9, $updated->version); + $this->assertSame('PUT /api/v1/preferences/global/appearance Bearer user-token', $this->sentCall(0)); + $this->assertSame(['value' => 'dark'], $this->sentJson(0)); + $this->assertSame(['', ''], $this->conditions(0)); + $this->assertSame(['"8"', ''], $this->conditions(1)); + } + + public function test_delete_global_preference(): void + { + $this->respond(204); + $this->respond(204); + + $this->owner()->deleteGlobalPreference('appearance'); + $this->owner()->deleteGlobalPreference('appearance', ifVersion: 9); + + $this->assertSame('DELETE /api/v1/preferences/global/appearance Bearer user-token', $this->sentCall(0)); + $this->assertSame('', (string) $this->sent[0]['request']->getBody()); + $this->assertSame(['', ''], $this->conditions(0)); + $this->assertSame(['"9"', ''], $this->conditions(1)); + } + + public function test_preferences_by_user_id(): void + { + $this->respond(200, ['data' => [$this->preference('acme', 'plan', 'pro'), $this->preference('acme', 'theme', ['accent' => 'blue'])]]); + + $preferences = app(AnikeenId::class)->preferencesByUserId('01JUSER', 'acme'); + + $this->assertEquals(['plan' => 'pro', 'theme' => (object) ['accent' => 'blue']], $preferences->values()); + $this->assertSame('blue', $preferences->find('theme')->value->accent); + $this->assertNull($preferences->find('missing')); + $this->assertSame('GET /api/v1/client/users/01JUSER/preferences/acme Bearer app-token', $this->sentCall(0)); + } + + public function test_preference_by_user_id(): void + { + $this->respond(200, ['data' => $this->preference('acme', 'plan', 'pro')]); + + $preference = app(AnikeenId::class)->preferenceByUserId('01JUSER', 'acme', 'plan'); + + $this->assertSame('pro', $preference->value); + $this->assertSame(7, $preference->version); + $this->assertSame('GET /api/v1/client/users/01JUSER/preferences/acme/plan Bearer app-token', $this->sentCall(0)); + } + + public function test_set_preference_by_user_id(): void + { + $this->respond(200, ['data' => $this->preference('acme', 'plan', 'pro')]); + $this->respond(200, ['data' => $this->preference('acme', 'plan', 'pro')]); + $this->respond(201, ['data' => $this->preference('acme', 'plan', 'pro')]); + + $anikeenId = app(AnikeenId::class); + $anikeenId->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro'); + $anikeenId->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro', ifVersion: 7); + $anikeenId->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro', createOnly: true); + + $this->assertSame('PUT /api/v1/client/users/01JUSER/preferences/acme/plan Bearer app-token', $this->sentCall(0)); + $this->assertSame(['value' => 'pro'], $this->sentJson(0)); + $this->assertSame(['', ''], $this->conditions(0)); + $this->assertSame(['"7"', ''], $this->conditions(1)); + $this->assertSame(['', '*'], $this->conditions(2)); + } + + public function test_delete_preference_by_user_id(): void + { + $this->respond(204); + $this->respond(204); + + app(AnikeenId::class)->deletePreferenceByUserId('01JUSER', 'acme', 'plan'); + app(AnikeenId::class)->deletePreferenceByUserId('01JUSER', 'acme', 'plan', ifVersion: 7); + + $this->assertSame('DELETE /api/v1/client/users/01JUSER/preferences/acme/plan Bearer app-token', $this->sentCall(0)); + $this->assertSame(['', ''], $this->conditions(0)); + $this->assertSame(['"7"', ''], $this->conditions(1)); + } + + public function test_find_user_id_by_preference(): void + { + $this->respond(200, ['data' => ['user_id' => '01JUSER', 'namespace' => 'acme', 'key' => 'customer_id', 'version' => 7, 'updated_at' => '2026-11-06T10:00:00.000000Z']]); + + $this->assertSame('01JUSER', app(AnikeenId::class)->findUserIdByPreference('acme', 'customer_id', 'cus_123')); + $this->assertSame('POST /api/v1/client/preferences/acme/lookup Bearer app-token', $this->sentCall(0)); + $this->assertSame(['key' => 'customer_id', 'value' => 'cus_123'], $this->sentJson(0)); + $this->assertSame('', $this->sent[0]['request']->getUri()->getQuery()); + } } diff --git a/tests/Id/PreferencesTest.php b/tests/Id/PreferencesTest.php new file mode 100644 index 0000000..a29ac7f --- /dev/null +++ b/tests/Id/PreferencesTest.php @@ -0,0 +1,226 @@ +anikeen_id = '01JUSER'; + $owner->anikeen_id_access_token = 'user-token'; + + return $owner; + } + + private function preference(string $key, mixed $value, int $version = 7): array + { + return ['data' => ['namespace' => 'global', 'key' => $key, 'value' => $value, 'version' => $version, 'updated_at' => '2026-11-06T10:00:00.000000Z', 'updated_by' => ['type' => 'user', 'client_id' => 'app-client']]]; + } + + private function path(int $index): string + { + return $this->sent[$index]['request']->getUri()->getPath(); + } + + public function test_the_lookup_returns_null_when_nobody_holds_the_value(): void + { + $this->respond(404, ['message' => 'The preference is not set.', 'error' => 'preference_not_found']); + + $this->assertNull(app(AnikeenId::class)->findUserIdByPreference('acme', 'customer_id', 'cus_123')); + } + + public function test_the_lookup_throws_on_other_not_found_answers(): void + { + $this->respond(404, ['message' => 'The namespace does not exist.', 'error' => 'namespace_not_found']); + + try { + app(AnikeenId::class)->findUserIdByPreference('acme', 'customer_id', 'cus_123'); + $this->fail('no exception'); + } catch (ApiException $e) { + $this->assertSame(404, $e->status); + $this->assertSame('namespace_not_found', $e->error); + } + } + + public function test_the_lookup_throws_on_a_404_without_an_error_code(): void + { + $this->respond(404, ['message' => 'Not Found']); + + $this->expectException(ApiException::class); + + app(AnikeenId::class)->findUserIdByPreference('acme', 'customer_id', 'cus_123'); + } + + public function test_the_lookup_throws_on_validation_errors(): void + { + $this->respond(422, ['message' => 'The key is not unique.', 'error' => 'key_not_unique']); + + try { + app(AnikeenId::class)->findUserIdByPreference('acme', 'plan', 'pro'); + $this->fail('no exception'); + } catch (ApiException $e) { + $this->assertSame('key_not_unique', $e->error); + } + } + + public function test_a_failed_app_write_carries_the_current_version(): void + { + $this->respond(412, ['message' => 'The preference has another version.', 'error' => 'preference_version_mismatch', 'current_version' => 12]); + + try { + app(AnikeenId::class)->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro', ifVersion: 7); + $this->fail('no exception'); + } catch (PreconditionFailedException $e) { + $this->assertSame(412, $e->status); + $this->assertSame('preference_version_mismatch', $e->error); + $this->assertSame(12, $e->currentVersion()); + } + } + + public function test_a_create_only_write_of_a_set_key_fails(): void + { + $this->respond(412, ['message' => 'The preference is already set.', 'error' => 'preference_exists', 'current_version' => 3]); + + try { + app(AnikeenId::class)->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro', createOnly: true); + $this->fail('no exception'); + } catch (PreconditionFailedException $e) { + $this->assertSame('preference_exists', $e->error); + $this->assertSame(3, $e->currentVersion()); + } + } + + public function test_a_failed_user_write_carries_the_current_version(): void + { + $this->respond(412, ['message' => 'The preference has another version.', 'error' => 'preference_version_mismatch', 'current_version' => null]); + $this->respond(412, ['message' => 'The preference has another version.', 'error' => 'preference_version_mismatch', 'current_version' => 5]); + + foreach ([ + fn () => $this->owner()->setGlobalPreference('appearance', 'dark', ifVersion: 4), + fn () => $this->owner()->deleteGlobalPreference('appearance', ifVersion: 4), + ] as $i => $call) { + try { + $call(); + $this->fail('no exception'); + } catch (PreconditionFailedException $e) { + $this->assertSame([null, 5][$i], $e->currentVersion()); + } + } + } + + public function test_a_write_is_either_conditional_or_create_only(): void + { + $this->expectException(InvalidArgumentException::class); + + try { + app(AnikeenId::class)->setPreferenceByUserId('01JUSER', 'acme', 'plan', 'pro', ifVersion: 7, createOnly: true); + } finally { + $this->assertCount(0, $this->sent); + } + } + + public function test_writes_clear_the_cached_preferences(): void + { + $owner = $this->owner(); + + $this->respond(200, ['data' => [$this->preference('appearance', 'light')['data']]]); + $this->respond(200, $this->preference('appearance', 'dark', 8)); + $this->respond(200, ['data' => [$this->preference('appearance', 'dark', 8)['data']]]); + $this->respond(204); + $this->respond(200, ['data' => []]); + + $this->assertSame('light', $owner->globalPreference(SharedPreference::APPEARANCE)); + $this->assertSame('light', $owner->globalPreference(SharedPreference::APPEARANCE)); // cached + $owner->setGlobalPreference(SharedPreference::APPEARANCE, Appearance::DARK); + $this->assertSame('dark', $owner->globalPreference(SharedPreference::APPEARANCE)); + $owner->deleteGlobalPreference(SharedPreference::APPEARANCE); + $this->assertSame(Appearance::SYSTEM, $owner->globalPreference(SharedPreference::APPEARANCE, Appearance::SYSTEM)); + + $this->assertCount(5, $this->sent); + $this->assertSame(['GET', 'PUT', 'GET', 'DELETE', 'GET'], array_map(fn ($sent) => $sent['request']->getMethod(), $this->sent)); + } + + public function test_a_failed_write_clears_the_cached_preferences_too(): void + { + $owner = $this->owner(); + + $this->respond(200, ['data' => [$this->preference('appearance', 'light')['data']]]); + $this->respond(412, ['message' => 'x', 'error' => 'preference_version_mismatch', 'current_version' => 9]); + $this->respond(200, ['data' => [$this->preference('appearance', 'dark', 9)['data']]]); + + $owner->globalPreferences(); + + try { + $owner->setGlobalPreference('appearance', 'dark', ifVersion: 7); + } catch (PreconditionFailedException) { + } + + $this->assertSame('dark', $owner->globalPreference('appearance')); + $this->assertCount(3, $this->sent); + } + + public function test_path_segments_are_encoded(): void + { + $this->respond(200, ['data' => []]); + $this->respond(204); + $this->respond(200, ['data' => ['user_id' => '01JUSER']]); + + $anikeenId = app(AnikeenId::class); + $anikeenId->preferencesByUserId('a/b', 'x y'); + $anikeenId->deletePreferenceByUserId('01JUSER', 'acme', '../key?'); + $anikeenId->findUserIdByPreference('ac/me', 'k', 'v'); + + $this->assertSame('/api/v1/client/users/a%2Fb/preferences/x%20y', $this->path(0)); + $this->assertSame('/api/v1/client/users/01JUSER/preferences/acme/..%2Fkey%3F', $this->path(1)); + $this->assertSame('/api/v1/client/preferences/ac%2Fme/lookup', $this->path(2)); + } + + public function test_values_round_trip_as_json(): void + { + $this->respond(201, $this->preference('settings', (object) [])); + + $preference = app(AnikeenId::class)->setPreferenceByUserId('01JUSER', 'acme', 'settings', (object) []); + + $this->assertSame('{"value":{}}', (string) $this->sent[0]['request']->getBody()); + $this->assertEquals((object) [], $preference->value); + } + + public function test_a_deleted_user_is_not_found(): void + { + $this->respond(404, ['message' => 'The user does not exist.', 'error' => 'user_not_found']); + + try { + app(AnikeenId::class)->preferenceByUserId('01JGONE', 'acme', 'plan'); + $this->fail('no exception'); + } catch (ApiException $e) { + $this->assertTrue($e->isNotFound()); + $this->assertSame('user_not_found', $e->error); + } + } + + public function test_an_unreachable_service_is_temporary(): void + { + $this->http->append(new \GuzzleHttp\Exception\ConnectException('down', new \GuzzleHttp\Psr7\Request('DELETE', 'x'))); + + try { + $this->owner()->deleteGlobalPreference('appearance'); + $this->fail('no exception'); + } catch (ApiException $e) { + $this->assertTrue($e->isTemporary()); + } + } +} diff --git a/tests/Id/RequestHeadersTest.php b/tests/Id/RequestHeadersTest.php new file mode 100644 index 0000000..ac03b30 --- /dev/null +++ b/tests/Id/RequestHeadersTest.php @@ -0,0 +1,113 @@ +respond(200, ['data' => []]); + + AppRequest::send('GET', 'v1/client/subscriptions', null, ['page' => 1]); + + $request = $this->sent[0]['request']; + $this->assertSame(['Bearer app-token'], $request->getHeader('Authorization')); + $this->assertSame(['app-client'], $request->getHeader('Client-ID')); + $this->assertFalse($request->hasHeader('If-Match')); + $this->assertFalse($request->hasHeader('If-None-Match')); + $this->assertSame('page=1', $request->getUri()->getQuery()); + } + + public function test_app_requests_send_extra_headers_also_on_the_retry_with_a_fresh_token(): void + { + $this->respond(401, ['message' => 'Unauthenticated.']); + $this->respond(200, ['data' => []]); + + AppRequest::send('PUT', 'v1/x', ['value' => 1], [], ['If-Match' => '"7"']); + + $this->assertSame('"7"', $this->sent[0]['request']->getHeaderLine('If-Match')); + $this->assertSame('"7"', $this->sent[1]['request']->getHeaderLine('If-Match')); + $this->assertSame('Bearer fresh-app-token', $this->sent[1]['request']->getHeaderLine('Authorization')); + } + + public function test_extra_headers_do_not_replace_the_client_and_token_headers(): void + { + $this->respond(200, ['data' => []]); + + app(AnikeenId::class)->withToken('user-token') + ->put('v1/x', ['value' => 1], [], null, ['If-None-Match' => '*', 'Authorization' => 'Bearer other']); + + $request = $this->sent[0]['request']; + $this->assertSame('*', $request->getHeaderLine('If-None-Match')); + $this->assertSame('Bearer user-token', $request->getHeaderLine('Authorization')); + } + + public function test_get_and_delete_helpers_take_headers(): void + { + $this->respond(200, ['data' => []]); + $this->respond(204); + + $anikeenId = app(AnikeenId::class)->withToken('user-token'); + $anikeenId->get('v1/x', [], null, ['If-None-Match' => '"3"']); + $anikeenId->delete('v1/x', [], [], null, ['If-Match' => '"3"']); + + $this->assertSame('"3"', $this->sent[0]['request']->getHeaderLine('If-None-Match')); + $this->assertSame('"3"', $this->sent[1]['request']->getHeaderLine('If-Match')); + } + + public function test_a_412_of_an_app_request_is_a_precondition_failed_exception(): void + { + $this->respond(412, ['message' => 'The preference has another version.', 'error' => 'preference_version_mismatch', 'current_version' => 12]); + + try { + AppRequest::send('PUT', 'v1/x', ['value' => 1], [], ['If-Match' => '"7"']); + $this->fail('no exception'); + } catch (PreconditionFailedException $e) { + $this->assertSame(412, $e->status); + $this->assertSame('preference_version_mismatch', $e->error); + $this->assertSame(12, $e->currentVersion()); + } + } + + public function test_a_412_of_a_user_request_is_a_precondition_failed_exception(): void + { + $this->respond(412, ['message' => 'The preference is already set.', 'error' => 'preference_exists', 'current_version' => null]); + + $result = app(AnikeenId::class)->withToken('user-token')->put('v1/x', ['value' => 1]); + $e = ApiException::fromResult($result); + + $this->assertInstanceOf(PreconditionFailedException::class, $e); + $this->assertSame('preference_exists', $e->error); + $this->assertNull($e->currentVersion()); + } + + public function test_list_errors_keep_their_collection_exception_type(): void + { + $this->respond(412, ['message' => 'x', 'error' => 'x']); + + $this->expectException(CollectionException::class); + + $anikeenId = app(AnikeenId::class)->withToken('user-token'); + SshKeys::builder(fn () => $anikeenId->get('v1/ssh-keys')); + } + + public function test_other_errors_stay_plain_api_exceptions(): void + { + $this->respond(409, ['message' => 'Taken.', 'error' => 'preference_value_taken']); + + try { + AppRequest::send('PUT', 'v1/x', ['value' => 1]); + $this->fail('no exception'); + } catch (ApiException $e) { + $this->assertSame(ApiException::class, $e::class); + $this->assertTrue($e->isConflict()); + } + } +} diff --git a/tests/Id/ScopeTest.php b/tests/Id/ScopeTest.php index 08b3642..8273452 100644 --- a/tests/Id/ScopeTest.php +++ b/tests/Id/ScopeTest.php @@ -25,6 +25,13 @@ class ScopeTest extends TestCase $this->assertSame($expected, $constant); } + public function test_the_preference_scopes_match_anikeen_id(): void + { + $this->assertSame('preferences', Scope::PREFERENCES); + $this->assertSame('preferences:read', Scope::PREFERENCES_READ); + $this->assertSame('preferences:client', Scope::PREFERENCES_CLIENT); + } + 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 diff --git a/tests/Id/SharedPreferenceTest.php b/tests/Id/SharedPreferenceTest.php new file mode 100644 index 0000000..795408c --- /dev/null +++ b/tests/Id/SharedPreferenceTest.php @@ -0,0 +1,16 @@ +assertSame('global', SharedPreference::NAMESPACE); + $this->assertSame('appearance', SharedPreference::APPEARANCE); + $this->assertSame(['system', 'light', 'dark'], Appearance::all()); + } +} diff --git a/tests/Id/WebhookTest.php b/tests/Id/WebhookTest.php index d9e75b0..5061bb3 100644 --- a/tests/Id/WebhookTest.php +++ b/tests/Id/WebhookTest.php @@ -70,6 +70,12 @@ class WebhookTest extends TestCase $this->assertContains(EventType::SUBSCRIPTION_RENEWED, EventType::all()); } + public function test_refunds_have_their_own_event_type(): void + { + $this->assertSame('order.refunded', EventType::ORDER_REFUNDED); + $this->assertContains(EventType::ORDER_REFUNDED, EventType::all()); + } + public function test_a_change_carries_the_previous_attributes(): void { $this->payload = json_encode(['id' => '01JEVT', 'type' => EventType::SUBSCRIPTION_UPDATED, 'created' => time(), 'data' => [