From 93f6d79a0ace29855d5dc69b49802b8d71f22a06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Maurice=20Preu=C3=9F?= Date: Sat, 7 Nov 2026 00:33:41 +0000 Subject: [PATCH] Let users change their profile and avatar with their token --- src/Id/AnikeenId.php | 26 ++++ src/Id/Concerns/ManagesProfile.php | 106 ++++++++++++++ tests/Id/UserProfileTest.php | 216 +++++++++++++++++++++++++++++ 3 files changed, 348 insertions(+) create mode 100644 tests/Id/UserProfileTest.php diff --git a/src/Id/AnikeenId.php b/src/Id/AnikeenId.php index c1e2b77..5a92d18 100644 --- a/src/Id/AnikeenId.php +++ b/src/Id/AnikeenId.php @@ -397,6 +397,32 @@ class AnikeenId return $result; } + /** + * Send a multipart/form-data request (file uploads), with the same headers and error handling + * as request(), which stays JSON only. + * + * @param list}> $multipart + * Guzzle's multipart parts + * @throws GuzzleException + * @throws RequestRequiresClientIdException + */ + public function requestMultipart(string $method, string $path, array $multipart): Result + { + try { + $response = $this->client->request($method, $path, [ + // no Content-Type: Guzzle sets it with the boundary + 'headers' => $this->buildHeaders(), + 'multipart' => $multipart, + ]); + + $result = new Result($response, null, $this); + } catch (RequestException $exception) { + $result = new Result($exception->getResponse(), $exception, $this); + } + + return $result; + } + /** * Build headers for request. * diff --git a/src/Id/Concerns/ManagesProfile.php b/src/Id/Concerns/ManagesProfile.php index 5ab113c..b871f44 100644 --- a/src/Id/Concerns/ManagesProfile.php +++ b/src/Id/Concerns/ManagesProfile.php @@ -2,8 +2,19 @@ namespace Anikeen\Id\Concerns; +use Anikeen\Id\Exceptions\ApiException; +use Anikeen\Id\Exceptions\ValidationFailedException; +use Anikeen\Id\Resources\User; +use Anikeen\Id\Result; +use GuzzleHttp\Exception\GuzzleException; +use GuzzleHttp\Psr7\Utils; +use SplFileInfo; use Throwable; +/** + * The user's own profile, with the user's token. Changes need the scope `user` (`user:read` + * only reads; 403 "missing_scope"). + */ trait ManagesProfile { use HasBillable; @@ -23,4 +34,99 @@ trait ManagesProfile 'options' => $options, ])->data->url; } + + /** + * Change the user's profile (scope `user`). Only the fields sent change. A new email address + * doesn't replace the current one: Anikeen ID mails a confirmation link to it, and it becomes + * the user's address once they confirm it signed in to Anikeen ID (see pendingEmail() of the + * result; user.updated follows then). Clears the cached getUserData(). + * + * @param array{ + * name?: string, + * username?: string, + * email?: string + * } $attributes The fields to change + * - name: The display name + * - username: The username (unique regardless of case) + * - email: A new email address (lower-cased; needs the user's confirmation) + * @return User the user as `GET v1/user` returns them, plus `pending_email` and + * `pending_email_expires_at` + * @throws ValidationFailedException 422 with the codes "name_invalid", "username_taken", + * "username_invalid", "email_taken", "email_invalid" (errorCodeFor($field)) + * @throws ApiException 401 "unauthenticated", 403 "missing_scope", "user_token_required", + * 429 "too_many_requests" (30 a minute per app and user, new email addresses 5 an hour) + */ + public function updateProfile(array $attributes): User + { + $anikeenId = $this->anikeenId(); + + return $this->sendProfileRequest(fn () => $anikeenId->request('PATCH', 'v1/user/profile', $attributes)); + } + + /** + * Upload a new profile picture (scope `user`): jpeg, png, webp or gif, at most 5 MB, 64 to + * 8000 pixels per side. The avatar URL stays the same, the picture behind it changes. Clears + * the cached getUserData(). + * + * @param string|SplFileInfo $file a path or a file, e.g. an UploadedFile + * @param array{x: int, y: int, size: int}|null $crop the square to use, in pixels of the + * upright picture; the centre without one + * @throws ValidationFailedException 422 with the codes "avatar_invalid", "avatar_too_large", + * "crop_invalid" + * @throws ApiException 413 "avatar_too_large", 401, 403 "missing_scope", "user_token_required", + * 429 "too_many_requests" (also more than 10 uploads an hour) + */ + public function uploadAvatar(string|SplFileInfo $file, ?array $crop = null): User + { + $path = $file instanceof SplFileInfo ? $file->getPathname() : $file; + $filename = $file instanceof SplFileInfo && method_exists($file, 'getClientOriginalName') + ? $file->getClientOriginalName() + : basename($path); + + $multipart = [['name' => 'avatar', 'contents' => Utils::tryFopen($path, 'r'), 'filename' => $filename]]; + + foreach (['x', 'y', 'size'] as $key) { + if (isset($crop[$key])) { + $multipart[] = ['name' => "crop[{$key}]", 'contents' => (string) $crop[$key]]; + } + } + + $anikeenId = $this->anikeenId(); + + return $this->sendProfileRequest(fn () => $anikeenId->requestMultipart('POST', 'v1/user/profile/avatar', $multipart)); + } + + /** + * Remove the uploaded profile picture (scope `user`); the initials are shown again when it + * was the one shown. Clears the cached getUserData(). + * + * @throws ApiException 401, 403 "missing_scope", "user_token_required", 429 "too_many_requests" + */ + public function removeAvatar(): User + { + $anikeenId = $this->anikeenId(); + + return $this->sendProfileRequest(fn () => $anikeenId->request('DELETE', 'v1/user/profile/avatar')); + } + + /** + * @param callable(): Result $send + * @throws ApiException + */ + private function sendProfileRequest(callable $send): User + { + try { + $result = $send(); + } catch (GuzzleException $e) { + throw ApiException::unreachable($e); + } finally { + $this->userDataCache = null; + } + + if (!$result->success()) { + throw ApiException::fromResult($result); + } + + return new User($result->data); + } } \ No newline at end of file diff --git a/tests/Id/UserProfileTest.php b/tests/Id/UserProfileTest.php new file mode 100644 index 0000000..a7c94de --- /dev/null +++ b/tests/Id/UserProfileTest.php @@ -0,0 +1,216 @@ +anikeen_id = '01JUSER'; + $owner->anikeen_id_access_token = 'user-token'; + + return $owner; + } + + private function sentCall(int $index): string + { + $request = $this->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, + ]]; + } + + private function validationError(array $codes): array + { + return [ + 'message' => 'The given data was invalid.', + 'error' => array_values($codes)[0], + 'errors' => array_map(fn () => ['The value is invalid.'], $codes), + 'codes' => $codes, + ]; + } + + public function test_update_profile_patches_the_fields_with_the_user_token(): void + { + $this->respond(200, $this->profile(['name' => 'Ada Lovelace', 'profile_version' => 9, + 'pending_email' => 'new@example.com', 'pending_email_expires_at' => '2026-11-07T11:00:00.000000Z'])); + + $user = $this->owner()->updateProfile(['name' => 'Ada Lovelace', 'email' => 'new@example.com']); + + $this->assertSame('PATCH /api/v1/user/profile Bearer user-token', $this->sentCall(0)); + $this->assertSame(['name' => 'Ada Lovelace', 'email' => 'new@example.com'], $this->sentJson(0)); + $this->assertSame('Ada Lovelace', $user->name()); + $this->assertSame('ada@example.com', $user->email()); + $this->assertSame('new@example.com', $user->pendingEmail()); + $this->assertEquals(new DateTimeImmutable('2026-11-07T11:00:00Z'), $user->pendingEmailExpiresAt()); + $this->assertSame(9, $user->version()); + } + + public function test_update_profile_clears_the_cached_user_data(): void + { + $owner = $this->owner(); + $this->respond(200, $this->profile()); + $this->respond(200, $this->profile(['name' => 'Ada Lovelace'])); + $this->respond(200, $this->profile(['name' => 'Ada Lovelace'])); + + $this->assertSame('Ada King', $owner->getUserData()->name); + $owner->updateProfile(['name' => 'Ada Lovelace']); + + $this->assertSame('Ada Lovelace', $owner->getUserData()->name); + $this->assertCount(3, $this->sent); + $this->assertSame('GET /api/v1/user Bearer user-token', $this->sentCall(2)); + } + + public function test_refused_profile_changes_carry_codes_per_field(): void + { + $this->respond(422, $this->validationError(['username' => 'username_taken', 'email' => 'email_invalid'])); + + try { + $this->owner()->updateProfile(['username' => 'taken', 'email' => 'nope']); + $this->fail('No exception thrown'); + } catch (ValidationFailedException $e) { + $this->assertSame(422, $e->status); + $this->assertSame('username_taken', $e->error); + $this->assertSame(['username' => 'username_taken', 'email' => 'email_invalid'], $e->errorCodes()); + $this->assertSame('email_invalid', $e->errorCodeFor('email')); + $this->assertNull($e->errorCodeFor('name')); + $this->assertTrue($e->hasErrorCode('email_invalid')); + $this->assertFalse($e->hasErrorCode('email_taken')); + $this->assertSame(['The value is invalid.'], $e->errors()['username']); + $this->assertInstanceOf(ApiException::class, $e); + } + } + + public function test_a_client_credentials_token_is_refused(): void + { + $this->respond(403, ['message' => 'This endpoint needs an access token of the user.', 'error' => 'user_token_required']); + + try { + $this->owner()->updateProfile(['name' => 'Ada']); + $this->fail('No exception thrown'); + } catch (ApiException $e) { + $this->assertNotInstanceOf(ValidationFailedException::class, $e); + $this->assertSame('user_token_required', $e->error); + } + } + + public function test_an_unreachable_service_is_an_api_exception(): void + { + $this->http->append(new ConnectException('Connection refused', new Request('PATCH', 'v1/user/profile'))); + + try { + $this->owner()->updateProfile(['name' => 'Ada']); + $this->fail('No exception thrown'); + } catch (ApiException $e) { + $this->assertSame(0, $e->status); + $this->assertTrue($e->isTemporary()); + } + } + + public function test_upload_avatar_sends_the_picture_as_multipart(): void + { + $path = tempnam(sys_get_temp_dir(), 'avatar'); + file_put_contents($path, 'picture-bytes'); + $this->respond(200, $this->profile(['profile_version' => 10])); + + try { + $user = $this->owner()->uploadAvatar($path, ['x' => 10, 'y' => 20, 'size' => 300]); + } finally { + unlink($path); + } + + $request = $this->sent[0]['request']; + $body = (string) $request->getBody(); + + $this->assertSame('POST /api/v1/user/profile/avatar Bearer user-token', $this->sentCall(0)); + $this->assertStringStartsWith('multipart/form-data; boundary=', $request->getHeaderLine('Content-Type')); + $this->assertStringContainsString('name="avatar"; filename="' . basename($path) . '"', $body); + $this->assertStringContainsString('picture-bytes', $body); + $this->assertStringContainsString("name=\"crop[x]\"\r\nContent-Length: 2\r\n\r\n10", $body); + $this->assertStringContainsString('name="crop[y]"', $body); + $this->assertStringContainsString('name="crop[size]"', $body); + $this->assertSame(10, $user->version()); + } + + public function test_upload_avatar_without_crop_sends_only_the_picture(): void + { + $path = tempnam(sys_get_temp_dir(), 'avatar'); + file_put_contents($path, 'picture-bytes'); + $this->respond(200, $this->profile()); + + try { + $this->owner()->uploadAvatar(new \SplFileInfo($path)); + } finally { + unlink($path); + } + + $body = (string) $this->sent[0]['request']->getBody(); + $this->assertStringContainsString('name="avatar"', $body); + $this->assertStringNotContainsString('crop', $body); + } + + public function test_a_refused_upload_carries_its_code(): void + { + $path = tempnam(sys_get_temp_dir(), 'avatar'); + file_put_contents($path, 'not a picture'); + $this->respond(422, $this->validationError(['avatar' => 'avatar_invalid'])); + $this->respond(413, ['message' => 'The picture is too large.', 'error' => 'avatar_too_large']); + + try { + try { + $this->owner()->uploadAvatar($path); + $this->fail('No exception thrown'); + } catch (ValidationFailedException $e) { + $this->assertSame('avatar_invalid', $e->errorCodeFor('avatar')); + } + + try { + $this->owner()->uploadAvatar($path); + $this->fail('No exception thrown'); + } catch (ApiException $e) { + $this->assertSame(413, $e->status); + $this->assertSame('avatar_too_large', $e->error); + } + } finally { + unlink($path); + } + } + + public function test_remove_avatar_deletes_the_upload_and_clears_the_cache(): void + { + $owner = $this->owner(); + $this->respond(200, $this->profile()); + $this->respond(200, $this->profile(['profile_version' => 11])); + $this->respond(200, $this->profile(['profile_version' => 11])); + + $owner->getUserData(); + $user = $owner->removeAvatar(); + $owner->getUserData(); + + $this->assertSame('DELETE /api/v1/user/profile/avatar Bearer user-token', $this->sentCall(1)); + $this->assertSame(11, $user->version()); + $this->assertCount(3, $this->sent); + } +}