From 802f52ce9399183c3894f464efe332d218a6e58d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Fri, 6 Nov 2026 19:28:23 +0000 Subject: [PATCH] Add the preferences API for apps and users --- src/Id/AnikeenId.php | 2 + src/Id/Billable.php | 4 + src/Id/Concerns/ManagesPreferences.php | 107 ++++++++++ src/Id/Concerns/ManagesUserPreferences.php | 96 +++++++++ src/Id/Resources/Preference.php | 27 +++ src/Id/Resources/Preferences.php | 68 +++++++ src/Id/Support/PreferenceRequest.php | 42 ++++ tests/Id/BillableModelTest.php | 2 + tests/Id/EndpointsTest.php | 129 ++++++++++++ tests/Id/PreferencesTest.php | 226 +++++++++++++++++++++ 10 files changed, 703 insertions(+) create mode 100644 src/Id/Concerns/ManagesPreferences.php create mode 100644 src/Id/Concerns/ManagesUserPreferences.php create mode 100644 src/Id/Resources/Preference.php create mode 100644 src/Id/Resources/Preferences.php create mode 100644 src/Id/Support/PreferenceRequest.php create mode 100644 tests/Id/PreferencesTest.php diff --git a/src/Id/AnikeenId.php b/src/Id/AnikeenId.php index f19fee0..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; 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/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/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/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()); + } + } +}