diff --git a/README.md b/README.md index df5611e..8046300 100644 --- a/README.md +++ b/README.md @@ -1187,7 +1187,7 @@ $user->removeAvatar(); // back | 401 | `unauthenticated` | | 403 | `missing_scope` (no `user` scope), `user_token_required` (an app token: there is no user to change) | | 413 | `avatar_too_large` (the upload is larger than the server accepts) | -| 422 | `ValidationFailedException`, codes per field: `name_invalid`, `username_taken`, `username_invalid`, `email_taken`, `email_invalid`, `avatar_invalid`, `avatar_too_large`, `crop_invalid`, else `validation_failed` | +| 422 | `ValidationFailedException`, codes per field: `name_invalid`, `username_taken`, `username_invalid`, `email_taken`, `email_invalid`, `avatar_invalid`, `avatar_too_large`, `crop_invalid` (keyed by `crop.x`, `crop.y` or `crop.size`; `errorCodeFor('crop')` finds it), else `validation_failed` | | 429 | `too_many_requests` | Branch on the codes, the messages are for people and may change. diff --git a/README.stub b/README.stub index bc17847..16dd144 100644 --- a/README.stub +++ b/README.stub @@ -1187,7 +1187,7 @@ $user->removeAvatar(); // back | 401 | `unauthenticated` | | 403 | `missing_scope` (no `user` scope), `user_token_required` (an app token: there is no user to change) | | 413 | `avatar_too_large` (the upload is larger than the server accepts) | -| 422 | `ValidationFailedException`, codes per field: `name_invalid`, `username_taken`, `username_invalid`, `email_taken`, `email_invalid`, `avatar_invalid`, `avatar_too_large`, `crop_invalid`, else `validation_failed` | +| 422 | `ValidationFailedException`, codes per field: `name_invalid`, `username_taken`, `username_invalid`, `email_taken`, `email_invalid`, `avatar_invalid`, `avatar_too_large`, `crop_invalid` (keyed by `crop.x`, `crop.y` or `crop.size`; `errorCodeFor('crop')` finds it), else `validation_failed` | | 429 | `too_many_requests` | Branch on the codes, the messages are for people and may change. diff --git a/src/Id/Concerns/ManagesProfile.php b/src/Id/Concerns/ManagesProfile.php index b871f44..6ff2cdc 100644 --- a/src/Id/Concerns/ManagesProfile.php +++ b/src/Id/Concerns/ManagesProfile.php @@ -72,9 +72,10 @@ trait ManagesProfile * @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" + * "crop_invalid" (keyed by crop.x, crop.y or crop.size; errorCodeFor('crop') finds them) * @throws ApiException 413 "avatar_too_large", 401, 403 "missing_scope", "user_token_required", * 429 "too_many_requests" (also more than 10 uploads an hour) + * @throws \RuntimeException when the file can't be opened (nothing is sent then) */ public function uploadAvatar(string|SplFileInfo $file, ?array $crop = null): User { @@ -129,4 +130,4 @@ trait ManagesProfile return new User($result->data); } -} \ No newline at end of file +} \ No newline at end of file diff --git a/src/Id/Exceptions/ValidationFailedException.php b/src/Id/Exceptions/ValidationFailedException.php index f9ffd6a..7c6b925 100644 --- a/src/Id/Exceptions/ValidationFailedException.php +++ b/src/Id/Exceptions/ValidationFailedException.php @@ -19,9 +19,25 @@ class ValidationFailedException extends ApiException return is_object($codes) || is_array($codes) ? array_map('strval', (array) $codes) : []; } + /** + * The code of $field, or of its first failed sub-field when the codes are keyed by those + * (`crop` finds `crop.x`, `crop.y` or `crop.size`). + */ public function errorCodeFor(string $field): ?string { - return $this->errorCodes()[$field] ?? null; + $codes = $this->errorCodes(); + + if (isset($codes[$field])) { + return $codes[$field]; + } + + foreach ($codes as $key => $code) { + if (str_starts_with((string) $key, $field . '.')) { + return $code; + } + } + + return null; } /** diff --git a/tests/Id/UserProfileTest.php b/tests/Id/UserProfileTest.php index a7c94de..bea1366 100644 --- a/tests/Id/UserProfileTest.php +++ b/tests/Id/UserProfileTest.php @@ -198,6 +198,36 @@ class UserProfileTest extends TestCase } } + public function test_crop_codes_are_found_under_the_crop_field(): void + { + $path = tempnam(sys_get_temp_dir(), 'avatar'); + file_put_contents($path, 'a picture'); + $this->respond(422, $this->validationError(['crop.size' => 'crop_invalid'])); + + try { + $this->owner()->uploadAvatar($path, ['x' => 0, 'y' => 0]); + $this->fail('No exception thrown'); + } catch (ValidationFailedException $e) { + $this->assertSame('crop_invalid', $e->errorCodeFor('crop')); + $this->assertSame('crop_invalid', $e->errorCodeFor('crop.size')); + $this->assertNull($e->errorCodeFor('cro')); + $this->assertNull($e->errorCodeFor('avatar')); + } finally { + unlink($path); + } + } + + public function test_an_unreadable_file_throws_before_anything_is_sent(): void + { + try { + $this->owner()->uploadAvatar(sys_get_temp_dir() . '/missing-avatar-' . uniqid() . '.png'); + $this->fail('No exception thrown'); + } catch (\RuntimeException $e) { + $this->assertNotInstanceOf(ApiException::class, $e); + $this->assertCount(0, $this->sent); + } + } + public function test_remove_avatar_deletes_the_upload_and_clears_the_cache(): void { $owner = $this->owner();