mirror of
https://github.com/anikeen-com/id.git
synced 2026-11-06 23:58:35 +00:00
Add the preferences API for apps and users
This commit is contained in:
10 files changed
+703
No files matched your search
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user