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
@@ -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());
}
}
}