Add the preferences API for apps and users

This commit is contained in:
envoyr committed 2026-11-06 19:28:23 +00:00
1 parent 90dac3a5a6
commit 802f52ce93
10 files changed
+703

No files matched your search

+2
View File
@@ -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;
+4
View File
@@ -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<string, \Anikeen\Id\Resources\Preferences> by namespace */
protected array $preferencesCache = [];
protected mixed $subscriptionsCache = null;
protected mixed $transactionsCache = null;
protected mixed $userDataCache = null;
+107
View File
@@ -0,0 +1,107 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\PreconditionFailedException;
use Anikeen\Id\Resources\Preference;
use Anikeen\Id\Resources\Preferences;
use Anikeen\Id\Support\AppRequest;
use Anikeen\Id\Support\PreferenceRequest;
/**
* Preferences of any user, with the app's own token (client credentials, scope
* `preferences:client`). Anikeen has to register the app for each namespace it uses: its own
* namespaces (read, write, lookup), read access to another app's namespace, or access to `global`.
* Without it every call fails with 403 "client_not_trusted" or 404 "namespace_not_found".
*/
trait ManagesPreferences
{
/**
* All preferences of a user in a namespace, sorted by key.
*
* @throws ApiException 404 "namespace_not_found", "user_not_found" (also deleted users), …
*/
public function preferencesByUserId(string $userId, string $namespace): Preferences
{
return Preferences::builder(fn () => 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);
}
}
@@ -0,0 +1,96 @@
<?php
namespace Anikeen\Id\Concerns;
use Anikeen\Id\Enums\SharedPreference;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\PreconditionFailedException;
use Anikeen\Id\Resources\Preference;
use Anikeen\Id\Resources\Preferences;
use Anikeen\Id\Support\PreferenceRequest;
use GuzzleHttp\Exception\GuzzleException;
/**
* The user's shared preferences (`global`, e.g. SharedPreference::APPEARANCE), with the user's
* token (scope `preferences` or `preferences:read`). User tokens reach `global` only; an app's
* own namespace needs the app token (AnikeenId::preferencesByUserId()).
*/
trait ManagesUserPreferences
{
/**
* The user's shared preferences, sorted by key. Cached on this instance; set and delete clear it.
*
* @throws ApiException
*/
public function globalPreferences(): Preferences
{
$namespace = SharedPreference::NAMESPACE;
if (!isset($this->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);
}
}
}
+27
View File
@@ -0,0 +1,27 @@
<?php
namespace Anikeen\Id\Resources;
/**
* A preference of a user: a JSON value under a namespace and a key. `version` is opaque: it only
* goes up (per user, not consecutive per key) and is what ifVersion (`If-Match`) compares.
* JSON objects come back as stdClass, so `{}` stays an object.
*
* @property string $namespace
* @property string $key
* @property mixed $value
* @property int $version
* @property string $updated_at
* @property object{type: string, client_id: ?string} $updated_by `type` "user" (written with a user token) or "app"
*/
class Preference extends BaseResource
{
/**
* The write that returned this preference created the key (201). False for reads, updates and
* writes of the value already stored.
*/
public function wasCreated(): bool
{
return $this->result->response()?->getStatusCode() === 201;
}
}
+68
View File
@@ -0,0 +1,68 @@
<?php
namespace Anikeen\Id\Resources;
/**
* The preferences of a user in one namespace, sorted by key (at most 50, no pagination).
*/
class Preferences extends BaseCollection
{
/**
* The preference of a key; null when it isn't set. Reads the list, no request.
*/
public function find(string $id): ?Preference
{
$data = $this->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<string, mixed>
*/
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;
}
}
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace Anikeen\Id\Support;
use InvalidArgumentException;
/**
* @internal paths and preconditions of the preferences API
*/
final class PreferenceRequest
{
/**
* The path with every segment URL-encoded, e.g. path('v1/preferences', 'global', 'appearance').
*/
public static function path(string $base, string ...$segments): string
{
return $base . '/' . implode('/', array_map('rawurlencode', $segments));
}
/**
* `If-Match: "<version>"` (only if the stored version is this one) or `If-None-Match: *`
* (only if the key isn't set yet).
*
* @return array<string, string>
*/
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()
{
}
}
+2
View File
@@ -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';
+129
View File
@@ -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());
}
}
+226
View File
@@ -0,0 +1,226 @@
<?php
namespace Anikeen\Id\Tests;
use Anikeen\Id\AnikeenId;
use Anikeen\Id\Billable;
use Anikeen\Id\Enums\Appearance;
use Anikeen\Id\Enums\SharedPreference;
use Anikeen\Id\Exceptions\ApiException;
use Anikeen\Id\Exceptions\PreconditionFailedException;
use Illuminate\Database\Eloquent\Model;
use InvalidArgumentException;
class PreferencesTest 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 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());
}
}
}