diff --git a/src/Id/Concerns/ManagesUsers.php b/src/Id/Concerns/ManagesUsers.php index 68824e5..26b07a7 100644 --- a/src/Id/Concerns/ManagesUsers.php +++ b/src/Id/Concerns/ManagesUsers.php @@ -5,7 +5,9 @@ namespace Anikeen\Id\Concerns; use Anikeen\Id\ApiOperations\Get; use Anikeen\Id\ApiOperations\Post; +use Anikeen\Id\Exceptions\ApiException; use Anikeen\Id\Result; +use Anikeen\Id\Support\AppRequest; use Throwable; trait ManagesUsers @@ -24,20 +26,32 @@ trait ManagesUsers } /** - * Creates a new user on behalf of the current user. + * Finds the account of an email address or creates it (trusted apps, app token). The email + * address is matched regardless of case; a new account stores it in lower case, unverified. + * An existing account is returned unchanged (only a missing name is filled in). * * @param array{ - * first_name: null|string, - * last_name: null|string, - * username: null|string, + * name?: null|string, + * first_name?: null|string, + * last_name?: null|string, + * username?: null|string, * email: string, - * password: null|string + * password?: null|string, + * password_confirmation?: null|string, + * send_invitation?: bool * } $attributes The user data - * - first_name: The first name (optional) - * - last_name: The last name (optional) - * - username: The username (optional) - * - email: The email (required) - * - password: The password (optional, can be reset by the user if not provided) + * - name: The display name (optional) + * - first_name: The first name (optional, joined with last_name into name) + * - last_name: The last name (optional, joined with first_name into name) + * - username: The username (optional) + * - email: The email (required) + * - password: The password (optional, can be reset by the user if not provided) + * - password_confirmation: Defaults to password + * - send_invitation: Send the user an invitation mail with a link to set a password + * (optional, only when this call created the account without a + * password, never for an existing account) + * @return Result `data` is the user plus `created` (this call created the account) and + * `invitation_sent` (the invitation mail was sent); 201 when created, 200 when it existed * @throws Throwable */ public function createUser(array $attributes): Result @@ -57,6 +71,30 @@ trait ManagesUsers return $this->asApp()->post('v1/admin/users', $attributes); } + /** + * Looks up the account of an email address without creating or changing anything (trusted + * apps, app token). The email address is matched regardless of case. + * + * `data` is `object{id: string, name: ?string, email: string, email_verified: bool, + * has_billing_address: bool}`. + * + * @return Result|null null when no account uses the email address + * @throws ApiException when the request fails for another reason + */ + public function findUserByEmail(string $email): ?Result + { + try { + return AppRequest::send('POST', 'v1/admin/users/lookup', ['email' => $email]); + } catch (ApiException $e) { + // only Anikeen ID's own answer means "no account", 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/tests/Id/EndpointsTest.php b/tests/Id/EndpointsTest.php index 8704144..7bd81bb 100644 --- a/tests/Id/EndpointsTest.php +++ b/tests/Id/EndpointsTest.php @@ -4,6 +4,7 @@ namespace Anikeen\Id\Tests; use Anikeen\Id\AnikeenId; use Anikeen\Id\Billable; +use Anikeen\Id\Exceptions\ApiException; use Illuminate\Database\Eloquent\Model; /** @@ -90,6 +91,52 @@ class EndpointsTest extends TestCase $this->assertSame(['email' => 'erika@example.com', 'password' => 'secret-123', 'name' => 'Erika Mustermann', 'password_confirmation' => 'secret-123'], $this->sentJson(0)); } + public function test_create_user_can_send_an_invitation(): void + { + $this->respond(201, ['data' => ['id' => '01JNEW', 'email' => 'erika@example.com', 'created' => true, 'invitation_sent' => true]]); + + $result = app(AnikeenId::class)->createUser(['name' => 'Erika Mustermann', 'email' => 'erika@example.com', 'send_invitation' => true]); + + $this->assertSame('POST /api/v1/admin/users Bearer app-token', $this->sentCall(0)); + $this->assertSame(['name' => 'Erika Mustermann', 'email' => 'erika@example.com', 'send_invitation' => true], $this->sentJson(0)); + $this->assertTrue($result->data->created); + $this->assertTrue($result->data->invitation_sent); + } + + public function test_find_user_by_email_uses_the_lookup_endpoint(): void + { + $this->respond(200, ['data' => ['id' => '01JUSER', 'name' => 'Erika', 'email' => 'erika@example.com', 'email_verified' => true, 'has_billing_address' => false]]); + + $result = app(AnikeenId::class)->findUserByEmail('Erika@Example.com'); + + $this->assertSame('POST /api/v1/admin/users/lookup Bearer app-token', $this->sentCall(0)); + $this->assertSame(['email' => 'Erika@Example.com'], $this->sentJson(0)); + $this->assertSame('01JUSER', $result->data->id); + $this->assertFalse($result->data->has_billing_address); + } + + public function test_find_user_by_email_returns_null_without_an_account(): void + { + $this->respond(404, ['message' => 'User not found.', 'error' => 'user_not_found']); + + $this->assertNull(app(AnikeenId::class)->findUserByEmail('nobody@example.com')); + } + + public function test_find_user_by_email_throws_other_failures(): void + { + $this->respond(404, ['message' => 'Not found.']); // e.g. an Anikeen ID without the lookup + $this->respond(500, ['message' => 'Server Error']); + + foreach ([404, 500] as $status) { + try { + app(AnikeenId::class)->findUserByEmail('erika@example.com'); + $this->fail('Expected an ApiException.'); + } catch (ApiException $e) { + $this->assertSame($status, $e->status); + } + } + } + public function test_email_check_and_socialite_users(): void { $this->respond(200, ['data' => ['exists' => true]]);