diff --git a/README.md b/README.md index 951b84bb..6fa2be50 100644 --- a/README.md +++ b/README.md @@ -138,14 +138,14 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( ``` ## ⌛ Progress - -![Server & Client - 49 / 291](https://img.shields.io/badge/Server_&_Client-49%20%2F%20291-red?style=for-the-badge) + +![Server & Client - 51 / 291](https://img.shields.io/badge/Server_&_Client-51%20%2F%20291-red?style=for-the-badge) - -![Server - 9 / 201](https://img.shields.io/badge/Server-9%20%2F%20201-red?style=for-the-badge) + +![Server - 10 / 201](https://img.shields.io/badge/Server-10%20%2F%20201-red?style=for-the-badge) - -![Client - 40 / 90](https://img.shields.io/badge/Client-40%20%2F%2090-gold?style=for-the-badge) + +![Client - 41 / 90](https://img.shields.io/badge/Client-41%20%2F%2090-gold?style=for-the-badge) ### 🔑 Key | Icon | Definition | @@ -155,8 +155,8 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( | ❌ | There is currently no intention to implement the endpoint for the given SDK type (client or server) | ### Account - -![Account - 49 / 55](https://img.shields.io/badge/Account-49%20%2F%2055-forestgreen?style=for-the-badge) + +![Account - 51 / 55](https://img.shields.io/badge/Account-51%20%2F%2055-forestgreen?style=for-the-badge) | Endpoint | Client | Server | Notes | |:-:|:-:|:-:|:-:| @@ -190,7 +190,7 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( | [Create Email Password Session](https://appwrite.io/docs/references/1.6.x/client-rest/account#createEmailPasswordSession) | ✅ | ✅ | | | [Update Magic URL Session](https://appwrite.io/docs/references/1.6.x/client-rest/account#updateMagicURLSession) | ✅ | ✅ | | | [Create OAuth2 Session](https://appwrite.io/docs/references/1.6.x/client-rest/account#createOAuth2Session) | ✅ | ✅ | | -| [Update Phone Session](https://appwrite.io/docs/references/1.6.x/client-rest/account#updatePhoneSession) | ⬛ | ⬛ | | +| [Update Phone Session](https://appwrite.io/docs/references/1.6.x/client-rest/account#updatePhoneSession) | ✅ | ✅ | | | [Create Session](https://appwrite.io/docs/references/1.6.x/client-rest/account#createSession) | ✅ | ✅ | | | [Get Session](https://appwrite.io/docs/references/1.6.x/client-rest/account#getSession) | ✅ | ❌ | | | [Update Session](https://appwrite.io/docs/references/1.6.x/client-rest/account#updateSession) | ✅ | ❌ | | diff --git a/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs b/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs index 26d19693..da0ce56c 100644 --- a/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs +++ b/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs @@ -697,4 +697,21 @@ public async Task DeleteIdentity(DeleteIdentityRequest request) return e.GetExceptionResponse(); } } + + /// + public async Task> CreatePhoneToken(CreatePhoneTokenRequest request) + { + try + { + request.Validate(true); + + var result = await _accountApi.CreatePhoneToken(request); + + return result.GetApiResponse(); + } + catch (Exception e) + { + return e.GetExceptionResponse(); + } + } } diff --git a/src/PinguApps.Appwrite.Client/Clients/IAccountClient.cs b/src/PinguApps.Appwrite.Client/Clients/IAccountClient.cs index cc33b66c..440e88ca 100644 --- a/src/PinguApps.Appwrite.Client/Clients/IAccountClient.cs +++ b/src/PinguApps.Appwrite.Client/Clients/IAccountClient.cs @@ -328,8 +328,18 @@ public interface IAccountClient /// /// Delete an identity by its unique ID + /// Appwrite Docs /// /// The request content /// code 204 ofr success Task DeleteIdentity(DeleteIdentityRequest request); + + /// + /// Sends the user an SMS with a secret key for creating a session. If the provided user ID has not be registered, a new user will be created. Use the returned user ID and secret and submit a request to to complete the login process. The secret sent to the user's phone is valid for 15 minutes. + /// A user is limited to 10 active sessions at a time by default. Learn more about session limits. + /// Appwrite Docs + /// + /// The request content + /// The token + Task> CreatePhoneToken(CreatePhoneTokenRequest request); } diff --git a/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs b/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs index cad44f51..223d2e0f 100644 --- a/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs +++ b/src/PinguApps.Appwrite.Client/Internals/IAccountApi.cs @@ -123,4 +123,7 @@ internal interface IAccountApi : IBaseApi [Delete("/account/identities/{identityId}")] Task DeleteIdentity([Header("x-appwrite-session")] string session, string identityId); + + [Post("/account/tokens/phone")] + Task> CreatePhoneToken(CreatePhoneTokenRequest request); } diff --git a/src/PinguApps.Appwrite.Playground/App.cs b/src/PinguApps.Appwrite.Playground/App.cs index 12e9656c..1e77da0d 100644 --- a/src/PinguApps.Appwrite.Playground/App.cs +++ b/src/PinguApps.Appwrite.Playground/App.cs @@ -21,9 +21,10 @@ public async Task Run(string[] args) { _client.SetSession(_session); - var response = await _client.Account.DeleteIdentity(new DeleteIdentityRequest + var response = await _server.Account.CreatePhoneToken(new CreatePhoneTokenRequest { - IdentityId = "my identity id" + UserId = "664aac1a00113f82e620", + PhoneNumber = "+447500112374" }); Console.WriteLine(response.Result.Match( diff --git a/src/PinguApps.Appwrite.Server/Internals/IAccountApi.cs b/src/PinguApps.Appwrite.Server/Internals/IAccountApi.cs index 0b50a06a..949e7e51 100644 --- a/src/PinguApps.Appwrite.Server/Internals/IAccountApi.cs +++ b/src/PinguApps.Appwrite.Server/Internals/IAccountApi.cs @@ -26,4 +26,7 @@ internal interface IAccountApi : IBaseApi [Put("/account/sessions/magic-url")] Task> UpdateMagicUrlSession(UpdateMagicUrlSessionRequest request); + + [Post("/account/tokens/phone")] + Task> CreatePhoneToken(CreatePhoneTokenRequest request); } diff --git a/src/PinguApps.Appwrite.Server/Servers/AccountServer.cs b/src/PinguApps.Appwrite.Server/Servers/AccountServer.cs index 6dce1a02..7e594782 100644 --- a/src/PinguApps.Appwrite.Server/Servers/AccountServer.cs +++ b/src/PinguApps.Appwrite.Server/Servers/AccountServer.cs @@ -170,4 +170,21 @@ public AppwriteResult CreateOauth2Session(CreateOauth2Sessi return e.GetExceptionResponse(); } } + + /// + public async Task> CreatePhoneToken(CreatePhoneTokenRequest request) + { + try + { + request.Validate(true); + + var result = await _accountApi.CreatePhoneToken(request); + + return result.GetApiResponse(); + } + catch (Exception e) + { + return e.GetExceptionResponse(); + } + } } diff --git a/src/PinguApps.Appwrite.Server/Servers/IAccountServer.cs b/src/PinguApps.Appwrite.Server/Servers/IAccountServer.cs index ec529e74..588fffd3 100644 --- a/src/PinguApps.Appwrite.Server/Servers/IAccountServer.cs +++ b/src/PinguApps.Appwrite.Server/Servers/IAccountServer.cs @@ -90,4 +90,13 @@ public interface IAccountServer /// The request content /// The CreateOauth2Session object AppwriteResult CreateOauth2Session(CreateOauth2SessionRequest request); + + /// + /// Sends the user an SMS with a secret key for creating a session. If the provided user ID has not be registered, a new user will be created. Use the returned user ID and secret and submit a request to to complete the login process. The secret sent to the user's phone is valid for 15 minutes. + /// A user is limited to 10 active sessions at a time by default. Learn more about session limits. + /// Appwrite Docs + /// + /// The request content + /// The token + Task> CreatePhoneToken(CreatePhoneTokenRequest request); } diff --git a/src/PinguApps.Appwrite.Shared/Requests/CreatePhoneTokenRequest.cs b/src/PinguApps.Appwrite.Shared/Requests/CreatePhoneTokenRequest.cs new file mode 100644 index 00000000..b358e83f --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Requests/CreatePhoneTokenRequest.cs @@ -0,0 +1,22 @@ +using System.Text.Json.Serialization; +using PinguApps.Appwrite.Shared.Requests.Validators; + +namespace PinguApps.Appwrite.Shared.Requests; + +/// +/// The request to create a phone token +/// +public class CreatePhoneTokenRequest : BaseRequest +{ + /// + /// Unique Id. Choose a custom ID or generate a random ID with . Valid chars are a-z, A-Z, 0-9, period, hyphen, and underscore. Can't start with a special char. Max length is 36 chars. + /// + [JsonPropertyName("userId")] + public string UserId { get; set; } = string.Empty; + + /// + /// Phone number. Format this number with a leading '+' and a country code, e.g., +16175551212 + /// + [JsonPropertyName("phone")] + public string PhoneNumber { get; set; } = string.Empty; +} diff --git a/src/PinguApps.Appwrite.Shared/Requests/Validators/CreatePhoneTokenRequestValidator.cs b/src/PinguApps.Appwrite.Shared/Requests/Validators/CreatePhoneTokenRequestValidator.cs new file mode 100644 index 00000000..b654b2b7 --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Requests/Validators/CreatePhoneTokenRequestValidator.cs @@ -0,0 +1,16 @@ +using FluentValidation; + +namespace PinguApps.Appwrite.Shared.Requests.Validators; +public class CreatePhoneTokenRequestValidator : AbstractValidator +{ + public CreatePhoneTokenRequestValidator() + { + RuleFor(request => request.UserId) + .NotEmpty().WithMessage("User ID must not be empty.") + .Matches("^[a-zA-Z0-9][a-zA-Z0-9._-]{0,35}$").WithMessage("User ID must be alphanumeric and can include periods, hyphens, and underscores. It cannot start with a special character and must be at most 36 characters long."); + + RuleFor(request => request.PhoneNumber) + .NotEmpty().WithMessage("Phone number must not be empty.") + .Matches(@"^\+\d{1,15}$").WithMessage("Phone number must start with a '+' and include the country code, followed by up to 15 digits."); + } +} diff --git a/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.CreatePhoneToken.cs b/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.CreatePhoneToken.cs new file mode 100644 index 00000000..20ca556e --- /dev/null +++ b/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.CreatePhoneToken.cs @@ -0,0 +1,77 @@ +using System.Net; +using PinguApps.Appwrite.Shared.Requests; +using PinguApps.Appwrite.Shared.Tests; +using RichardSzalay.MockHttp; + +namespace PinguApps.Appwrite.Client.Tests.Clients.Account; +public partial class AccountClientTests +{ + [Fact] + public async Task CreatePhoneToken_ShouldReturnSuccess_WhenApiCallSucceeds() + { + // Arrange + var request = new CreatePhoneTokenRequest() + { + UserId = "123456", + PhoneNumber = "+16175551212" + }; + + _mockHttp.Expect(HttpMethod.Post, $"{Constants.Endpoint}/account/tokens/phone") + .ExpectedHeaders() + .WithJsonContent(request) + .Respond(Constants.AppJson, Constants.TokenResponse); + + // Act + var result = await _appwriteClient.Account.CreatePhoneToken(request); + + // Assert + Assert.True(result.Success); + } + + [Fact] + public async Task CreatePhoneToken_ShouldHandleException_WhenApiCallFails() + { + // Arrange + var request = new CreatePhoneTokenRequest() + { + UserId = "123456", + PhoneNumber = "+16175551212" + }; + + _mockHttp.Expect(HttpMethod.Post, $"{Constants.Endpoint}/account/tokens/phone") + .ExpectedHeaders() + .WithJsonContent(request) + .Respond(HttpStatusCode.BadRequest, Constants.AppJson, Constants.AppwriteError); + + // Act + var result = await _appwriteClient.Account.CreatePhoneToken(request); + + // Assert + Assert.True(result.IsError); + Assert.True(result.IsAppwriteError); + } + + [Fact] + public async Task CreatePhoneToken_ShouldReturnErrorResponse_WhenExceptionOccurs() + { + // Arrange + var request = new CreatePhoneTokenRequest() + { + UserId = "123456", + PhoneNumber = "+16175551212" + }; + + _mockHttp.Expect(HttpMethod.Post, $"{Constants.Endpoint}/account/tokens/phone") + .ExpectedHeaders() + .WithJsonContent(request) + .Throw(new HttpRequestException("An error occurred")); + + // Act + var result = await _appwriteClient.Account.CreatePhoneToken(request); + + // Assert + Assert.False(result.Success); + Assert.True(result.IsInternalError); + Assert.Equal("An error occurred", result.Result.AsT2.Message); + } +} diff --git a/tests/PinguApps.Appwrite.Server.Tests/Servers/Account/AccountServerTests.CreatePhoneToken.cs b/tests/PinguApps.Appwrite.Server.Tests/Servers/Account/AccountServerTests.CreatePhoneToken.cs new file mode 100644 index 00000000..d0a5d8fc --- /dev/null +++ b/tests/PinguApps.Appwrite.Server.Tests/Servers/Account/AccountServerTests.CreatePhoneToken.cs @@ -0,0 +1,77 @@ +using System.Net; +using PinguApps.Appwrite.Shared.Requests; +using PinguApps.Appwrite.Shared.Tests; +using RichardSzalay.MockHttp; + +namespace PinguApps.Appwrite.Server.Tests.Servers.Account; +public partial class AccountServerTests +{ + [Fact] + public async Task CreatePhoneToken_ShouldReturnSuccess_WhenApiCallSucceeds() + { + // Arrange + var request = new CreatePhoneTokenRequest() + { + UserId = "123456", + PhoneNumber = "+16175551212" + }; + + _mockHttp.Expect(HttpMethod.Post, $"{Constants.Endpoint}/account/tokens/phone") + .ExpectedHeaders() + .WithJsonContent(request) + .Respond(Constants.AppJson, Constants.TokenResponse); + + // Act + var result = await _appwriteServer.Account.CreatePhoneToken(request); + + // Assert + Assert.True(result.Success); + } + + [Fact] + public async Task CreatePhoneToken_ShouldHandleException_WhenApiCallFails() + { + // Arrange + var request = new CreatePhoneTokenRequest() + { + UserId = "123456", + PhoneNumber = "+16175551212" + }; + + _mockHttp.Expect(HttpMethod.Post, $"{Constants.Endpoint}/account/tokens/phone") + .ExpectedHeaders() + .WithJsonContent(request) + .Respond(HttpStatusCode.BadRequest, Constants.AppJson, Constants.AppwriteError); + + // Act + var result = await _appwriteServer.Account.CreatePhoneToken(request); + + // Assert + Assert.True(result.IsError); + Assert.True(result.IsAppwriteError); + } + + [Fact] + public async Task CreatePhoneToken_ShouldReturnErrorResponse_WhenExceptionOccurs() + { + // Arrange + var request = new CreatePhoneTokenRequest() + { + UserId = "123456", + PhoneNumber = "+16175551212" + }; + + _mockHttp.Expect(HttpMethod.Post, $"{Constants.Endpoint}/account/tokens/phone") + .ExpectedHeaders() + .WithJsonContent(request) + .Throw(new HttpRequestException("An error occurred")); + + // Act + var result = await _appwriteServer.Account.CreatePhoneToken(request); + + // Assert + Assert.False(result.Success); + Assert.True(result.IsInternalError); + Assert.Equal("An error occurred", result.Result.AsT2.Message); + } +} diff --git a/tests/PinguApps.Appwrite.Shared.Tests/Requests/CreatePhoneTokenRequestTests.cs b/tests/PinguApps.Appwrite.Shared.Tests/Requests/CreatePhoneTokenRequestTests.cs new file mode 100644 index 00000000..bc622c4d --- /dev/null +++ b/tests/PinguApps.Appwrite.Shared.Tests/Requests/CreatePhoneTokenRequestTests.cs @@ -0,0 +1,109 @@ +using FluentValidation; +using PinguApps.Appwrite.Shared.Requests; + +namespace PinguApps.Appwrite.Shared.Tests.Requests; +public class CreatePhoneTokenRequestTests +{ + [Fact] + public void Constructor_InitializesWithExpectedValues() + { + // Arrange & Act + var request = new CreatePhoneTokenRequest(); + + // Assert + Assert.Equal(string.Empty, request.UserId); + Assert.Equal(string.Empty, request.PhoneNumber); + } + + [Fact] + public void Properties_CanBeSet() + { + var userId = "123456"; + var phoneNumber = "+16175551212"; + + // Arrange + var request = new CreatePhoneTokenRequest(); + + // Act + request.UserId = userId; + request.PhoneNumber = phoneNumber; + + // Assert + Assert.Equal(userId, request.UserId); + Assert.Equal(phoneNumber, request.PhoneNumber); + } + + [Fact] + public void IsValid_WithValidData_ReturnsTrue() + { + // Arrange + var request = new CreatePhoneTokenRequest + { + UserId = "validUserId", + PhoneNumber = "+16175551212" + }; + + // Act + var isValid = request.IsValid(); + + // Assert + Assert.True(isValid); + } + + [Theory] + [InlineData("badChar^", "+16175551212")] + [InlineData(".bad", "+16175551212")] + [InlineData("_bad", "+16175551212")] + [InlineData("-bad", "+16175551212")] + [InlineData("", "+16175551212")] + [InlineData("1234567890123456789012345678901234567", "+16175551212")] + [InlineData("validUserId", "")] + [InlineData("validUserId", "123456")] + [InlineData("validUserId", "+1234567890123456789012345678901234567")] + public void IsValid_WithInvalidData_ReturnsFalse(string userId, string phoneNumber) + { + // Arrange + var request = new CreatePhoneTokenRequest + { + UserId = userId, + PhoneNumber = phoneNumber + }; + + // Act + var isValid = request.IsValid(); + + // Assert + Assert.False(isValid); + } + + [Fact] + public void Validate_WithThrowOnFailuresTrue_ThrowsValidationExceptionOnFailure() + { + // Arrange + var request = new CreatePhoneTokenRequest + { + UserId = ".badChar^", + PhoneNumber = "123456" + }; + + // Assert + Assert.Throws(() => request.Validate(true)); + } + + [Fact] + public void Validate_WithThrowOnFailuresFalse_ReturnsInvalidResultOnFailure() + { + // Arrange + var request = new CreatePhoneTokenRequest + { + UserId = ".badChar^", + PhoneNumber = "123456" + }; + + // Act + var result = request.Validate(false); + + // Assert + Assert.False(result.IsValid); + } +}