diff --git a/src/Id/Concerns/ManagesUsers.php b/src/Id/Concerns/ManagesUsers.php index 26b07a7..2c2ed78 100644 --- a/src/Id/Concerns/ManagesUsers.php +++ b/src/Id/Concerns/ManagesUsers.php @@ -6,6 +6,7 @@ namespace Anikeen\Id\Concerns; use Anikeen\Id\ApiOperations\Get; use Anikeen\Id\ApiOperations\Post; use Anikeen\Id\Exceptions\ApiException; +use Anikeen\Id\Resources\User; use Anikeen\Id\Result; use Anikeen\Id\Support\AppRequest; use Throwable; @@ -95,6 +96,30 @@ trait ManagesUsers } } + /** + * The profile of a user connected to your app, by their Anikeen ID (trusted apps, app token), + * e.g. to catch up on a missed user.updated webhook. The email address is returned even while + * it isn't verified (emailVerified() is false then). No scope needed. + * + * @return User|null null when the user is unknown, deleted or not connected to your app + * (404 "user_not_found"; the three look the same) + * @throws ApiException 403 "client_not_allowed" (not a trusted app), 429 "too_many_requests", + * or another failure + */ + public function findUserById(string $userId): ?User + { + try { + return new User(AppRequest::send('GET', sprintf('v1/client/users/%s', rawurlencode($userId)))->data); + } catch (ApiException $e) { + // only Anikeen ID's own answer means "no such user", not e.g. an unknown route + if ($e->isNotFound() && $e->error === 'user_not_found') { + return null; + } + + throw $e; + } + } + /** * Find the user of a social login or create it (trusted apps, app token). * diff --git a/src/Id/Resources/User.php b/src/Id/Resources/User.php new file mode 100644 index 0000000..c92e2fb --- /dev/null +++ b/src/Id/Resources/User.php @@ -0,0 +1,123 @@ +data = (object) $data; + } + + public function __get(string $name): mixed + { + return $this->data->{$name} ?? null; + } + + public function __isset(string $name): bool + { + return isset($this->data->{$name}); + } + + public function id(): string + { + return (string) ($this->data->id ?? ''); + } + + public function name(): ?string + { + return $this->data->name ?? null; + } + + public function username(): ?string + { + return $this->data->username ?? null; + } + + public function email(): ?string + { + return $this->data->email ?? null; + } + + /** + * From `email_verified` (webhooks) or `email_verified_at` (REST shapes). + */ + public function emailVerified(): bool + { + if (isset($this->data->email_verified)) { + return (bool) $this->data->email_verified; + } + + return !empty($this->data->email_verified_at); + } + + /** + * The URL of the profile picture. It stays the same when the picture changes (user.updated + * with `avatar` in `changed`): drop a cached copy then. + */ + public function avatar(): ?string + { + return $this->data->avatar ?? null; + } + + /** + * Grows with every change apps see (`profile_version` in REST shapes, `version` in webhooks): + * keep the highest one seen and ignore older data. + */ + public function version(): int + { + return (int) ($this->data->profile_version ?? $this->data->version ?? 0); + } + + /** + * A new email address waiting for the user's confirmation (profile updates only); `email` + * stays the current address until then. + */ + public function pendingEmail(): ?string + { + return $this->data->pending_email ?? null; + } + + public function pendingEmailExpiresAt(): ?DateTimeImmutable + { + return !empty($this->data->pending_email_expires_at) ? new DateTimeImmutable($this->data->pending_email_expires_at) : null; + } + + public function toArray(): array + { + return (array) $this->data; + } + + public function jsonSerialize(): array + { + return $this->toArray(); + } +} diff --git a/tests/Id/UserByIdTest.php b/tests/Id/UserByIdTest.php new file mode 100644 index 0000000..e57feff --- /dev/null +++ b/tests/Id/UserByIdTest.php @@ -0,0 +1,101 @@ +sent[$index]['request']; + + return $request->getMethod() . ' ' . $request->getUri()->getPath() . ' ' . $request->getHeaderLine('Authorization'); + } + + private function profile(array $overrides = []): array + { + return ['data' => [ + 'id' => '01JUSER', 'name' => 'Ada King', 'username' => 'ada', 'email' => 'ada@example.com', + 'email_verified_at' => '2026-11-01T10:00:00.000000Z', 'avatar' => 'https://id.test/avatars/01JUSER', + 'locale' => 'en', 'profile_version' => 8, 'created_at' => '2026-01-01T00:00:00.000000Z', + ...$overrides, + ]]; + } + + public function test_find_user_by_id_reads_a_connected_user_with_the_app_token(): void + { + $this->respond(200, $this->profile()); + + $user = app(AnikeenId::class)->findUserById('01JUSER'); + + $this->assertSame('GET /api/v1/client/users/01JUSER Bearer app-token', $this->sentCall(0)); + $this->assertInstanceOf(User::class, $user); + $this->assertSame('01JUSER', $user->id()); + $this->assertSame('Ada King', $user->name()); + $this->assertSame('ada', $user->username()); + $this->assertSame('ada@example.com', $user->email()); + $this->assertTrue($user->emailVerified()); + $this->assertSame('https://id.test/avatars/01JUSER', $user->avatar()); + $this->assertSame(8, $user->version()); + $this->assertSame('en', $user->locale); + $this->assertNull($user->pendingEmail()); + $this->assertSame('Ada King', $user->jsonSerialize()['name']); + } + + public function test_find_user_by_id_encodes_the_id(): void + { + $this->respond(200, $this->profile()); + + app(AnikeenId::class)->findUserById('a/b'); + + $this->assertSame('/api/v1/client/users/a%2Fb', $this->sent[0]['request']->getUri()->getPath()); + } + + public function test_find_user_by_id_returns_null_for_an_unknown_or_unconnected_user(): void + { + $this->respond(404, ['message' => 'User not found.', 'error' => 'user_not_found']); + + $this->assertNull(app(AnikeenId::class)->findUserById('01JOTHER')); + } + + public function test_find_user_by_id_throws_other_failures(): void + { + $this->respond(403, ['message' => 'Only trusted apps.', 'error' => 'client_not_allowed']); + + try { + app(AnikeenId::class)->findUserById('01JUSER'); + $this->fail('No exception thrown'); + } catch (ApiException $e) { + $this->assertSame(403, $e->status); + $this->assertSame('client_not_allowed', $e->error); + } + + // a 404 without Anikeen ID's code (e.g. an older release without the route) isn't "no user" + $this->respond(404, ['message' => 'Not Found']); + $this->expectException(ApiException::class); + app(AnikeenId::class)->findUserById('01JUSER'); + } + + public function test_an_unverified_address_is_not_verified(): void + { + $this->respond(200, $this->profile(['email_verified_at' => null])); + + $this->assertFalse(app(AnikeenId::class)->findUserById('01JUSER')->emailVerified()); + } + + public function test_a_user_updated_webhook_object_is_a_user(): void + { + $user = new User(['object' => 'user', 'id' => '01JUSER', 'name' => 'Ada King', 'username' => 'ada', + 'email' => null, 'email_verified' => false, 'avatar' => 'https://id.test/avatars/01JUSER', 'version' => 9]); + + $this->assertSame(9, $user->version()); + $this->assertFalse($user->emailVerified()); + $this->assertNull($user->email()); + $this->assertSame('user', $user->object); + $this->assertSame('user.updated', EventType::USER_UPDATED); + } +}