diff --git a/README.md b/README.md index 5cce3df8..7b751f02 100644 --- a/README.md +++ b/README.md @@ -138,14 +138,14 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( ``` ## ⌛ Progress - -![Server & Client - 34 / 288](https://img.shields.io/badge/Server_&_Client-34%20%2F%20288-red?style=for-the-badge) + +![Server & Client - 35 / 288](https://img.shields.io/badge/Server_&_Client-35%20%2F%20288-red?style=for-the-badge) ![Server - 2 / 195](https://img.shields.io/badge/Server-2%20%2F%20195-red?style=for-the-badge) - -![Client - 32 / 93](https://img.shields.io/badge/Client-32%20%2F%2093-red?style=for-the-badge) + +![Client - 33 / 93](https://img.shields.io/badge/Client-33%20%2F%2093-red?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 - 34 / 52](https://img.shields.io/badge/Account-34%20%2F%2052-yellow?style=for-the-badge) + +![Account - 35 / 52](https://img.shields.io/badge/Account-35%20%2F%2052-0af0?style=for-the-badge) | Endpoint | Client | Server | |:-:|:-:|:-:| @@ -189,7 +189,7 @@ string emailAddressOrErrorMessage = userResponse.Result.Match( | [Create Anonymous Session](https://appwrite.io/docs/references/1.5.x/client-rest/account#createAnonymousSession) | ✅ | ❌ | | [Create Email Password Session](https://appwrite.io/docs/references/1.5.x/client-rest/account#createEmailPasswordSession) | ✅ | ❌ | | [Update Magic URL Session](https://appwrite.io/docs/references/1.5.x/client-rest/account#updateMagicURLSession) | ⬛ | ❌ | -| [Create OAuth2 Session](https://appwrite.io/docs/references/1.5.x/client-rest/account#createOAuth2Session) | ⬛ | ❌ | +| [Create OAuth2 Session](https://appwrite.io/docs/references/1.5.x/client-rest/account#createOAuth2Session) | ✅ | ❌ | | [Update Phone Session](https://appwrite.io/docs/references/1.5.x/client-rest/account#updatePhoneSession) | ⬛ | ❌ | | [Create Session](https://appwrite.io/docs/references/1.5.x/client-rest/account#createSession) | ✅ | ❌ | | [Get Session](https://appwrite.io/docs/references/1.5.x/client-rest/account#getSession) | ✅ | ❌ | diff --git a/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs b/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs index 9f70f532..e905eebd 100644 --- a/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs +++ b/src/PinguApps.Appwrite.Client/Clients/AccountClient.cs @@ -16,10 +16,12 @@ namespace PinguApps.Appwrite.Client; public class AccountClient : IAccountClient, ISessionAware { private readonly IAccountApi _accountApi; + private readonly Config _config; - public AccountClient(IServiceProvider services) + public AccountClient(IServiceProvider services, Config config) { _accountApi = services.GetRequiredService(); + _config = config; } string? ISessionAware.Session { get; set; } @@ -561,4 +563,21 @@ public async Task> CreateEmailPasswordSession(CreateEmai return e.GetExceptionResponse(); } } + + /// + public AppwriteResult CreateOauth2Session(CreateOauth2SessionRequest request) + { + try + { + request.Validate(true); + + var uri = request.BuildUri(_config.Endpoint, _config.ProjectId); + + return new AppwriteResult(new CreateOauth2Session(uri.AbsoluteUri)); + } + 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 5bda3445..00e049e9 100644 --- a/src/PinguApps.Appwrite.Client/Clients/IAccountClient.cs +++ b/src/PinguApps.Appwrite.Client/Clients/IAccountClient.cs @@ -262,7 +262,17 @@ public interface IAccountClient /// A user is limited to 10 active sessions at a time by default. Learn more about session limits. /// Appwrite Docs /// - /// The request + /// The request content /// The Session Task> CreateEmailPasswordSession(CreateEmailPasswordSessionRequest request); + + /// + /// Allow the user to login to their account using the OAuth2 provider of their choice. Each OAuth2 provider should be enabled from the Appwrite console first. Use the success and failure arguments to provide a redirect URL's back to your app when login is completed. + /// If there is already an active session, the new session will be attached to the logged-in account. If there are no active sessions, the server will attempt to look for a user with the same email address as the email received from the OAuth2 provider and attach the new session to the existing user. If no matching user is found - the server will create a new user. + /// A user is limited to 10 active sessions at a time by default. Learn more about session limits + /// Appwrite Docs + /// + /// The request content + /// The CreateOauth2Session object + AppwriteResult CreateOauth2Session(CreateOauth2SessionRequest request); } diff --git a/src/PinguApps.Appwrite.Client/ServiceCollectionExtensions.cs b/src/PinguApps.Appwrite.Client/ServiceCollectionExtensions.cs index d83ece5b..a35cfb19 100644 --- a/src/PinguApps.Appwrite.Client/ServiceCollectionExtensions.cs +++ b/src/PinguApps.Appwrite.Client/ServiceCollectionExtensions.cs @@ -3,6 +3,7 @@ using Microsoft.Extensions.DependencyInjection; using PinguApps.Appwrite.Client.Handlers; using PinguApps.Appwrite.Client.Internals; +using PinguApps.Appwrite.Shared; using Refit; namespace PinguApps.Appwrite.Client; @@ -30,6 +31,8 @@ public static IServiceCollection AddAppwriteClient(this IServiceCollection servi .AddHttpMessageHandler() .AddHttpMessageHandler(); + services.AddSingleton(new Config(endpoint, projectId)); + services.AddSingleton(); services.AddSingleton(); services.AddSingleton(x => new Lazy(() => x.GetRequiredService())); @@ -60,6 +63,8 @@ public static IServiceCollection AddAppwriteClientForServer(this IServiceCollect } }); + services.AddSingleton(new Config(endpoint, projectId)); + services.AddSingleton(); services.AddSingleton(); diff --git a/src/PinguApps.Appwrite.Playground/App.cs b/src/PinguApps.Appwrite.Playground/App.cs index eeae6a35..5b583fc4 100644 --- a/src/PinguApps.Appwrite.Playground/App.cs +++ b/src/PinguApps.Appwrite.Playground/App.cs @@ -22,10 +22,12 @@ public async Task Run(string[] args) Console.WriteLine(_client.Session); - var response = await _client.Account.CreateEmailPasswordSession(new Shared.Requests.CreateEmailPasswordSessionRequest + var response = _client.Account.CreateOauth2Session(new Shared.Requests.CreateOauth2SessionRequest { - Email = "pingu@example.com", - Password = "password" + Provider = "google", + SuccessUri = "https://localhost:5001/success", + FailureUri = "https://localhost:5001/fail", + Scopes = ["scope1", "scope2"] }); Console.WriteLine(response.Result.Match( diff --git a/src/PinguApps.Appwrite.Shared/Attributes/QueryParameterAttribute.cs b/src/PinguApps.Appwrite.Shared/Attributes/QueryParameterAttribute.cs new file mode 100644 index 00000000..40409f4c --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Attributes/QueryParameterAttribute.cs @@ -0,0 +1,12 @@ +using System; + +namespace PinguApps.Appwrite.Shared.Attributes; +internal class QueryParameterAttribute : Attribute +{ + public QueryParameterAttribute(string key) + { + Key = key; + } + + public string Key { get; } +} diff --git a/src/PinguApps.Appwrite.Shared/Attributes/UrlReplacementAttribute.cs b/src/PinguApps.Appwrite.Shared/Attributes/UrlReplacementAttribute.cs new file mode 100644 index 00000000..d14cca21 --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Attributes/UrlReplacementAttribute.cs @@ -0,0 +1,13 @@ +using System; + +namespace PinguApps.Appwrite.Shared.Attributes; + +internal class UrlReplacementAttribute : Attribute +{ + public UrlReplacementAttribute(string pattern) + { + Pattern = pattern; + } + + public string Pattern { get; } +} diff --git a/src/PinguApps.Appwrite.Shared/Config.cs b/src/PinguApps.Appwrite.Shared/Config.cs new file mode 100644 index 00000000..c7109103 --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Config.cs @@ -0,0 +1,5 @@ +namespace PinguApps.Appwrite.Shared; +public record Config( + string Endpoint, + string ProjectId +); diff --git a/src/PinguApps.Appwrite.Shared/Requests/CreateOauth2SessionRequest.cs b/src/PinguApps.Appwrite.Shared/Requests/CreateOauth2SessionRequest.cs new file mode 100644 index 00000000..df2c1c53 --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Requests/CreateOauth2SessionRequest.cs @@ -0,0 +1,38 @@ +using System.Collections.Generic; +using PinguApps.Appwrite.Shared.Attributes; +using PinguApps.Appwrite.Shared.Requests.Validators; + +namespace PinguApps.Appwrite.Shared.Requests; + +/// +/// The request for creating an Oauth2 Session +/// +public class CreateOauth2SessionRequest : QueryParamBaseRequest +{ + /// + /// OAuth2 Provider. Currently, supported providers are: + /// amazon, apple, auth0, authentik, autodesk, bitbucket, bitly, box, dailymotion, discord, disqus, dropbox, etsy, facebook, github, gitlab, google, linkedin, microsoft, notion, oidc, okta, paypal, paypalSandbox, podio, salesforce, slack, spotify, stripe, tradeshift, tradeshiftBox, twitch, wordpress, yahoo, yammer, yandex, zoho, zoom + /// + [UrlReplacement("{provider}")] + public string Provider { get; set; } = string.Empty; + + /// + /// URL to redirect back to your app after a successful login attempt. Only URLs from hostnames in your project's platform list are allowed. This requirement helps to prevent an open redirect attack against your project API + /// + [QueryParameter("success")] + public string? SuccessUri { get; set; } + + /// + /// URL to redirect back to your app after a failed login attempt. Only URLs from hostnames in your project's platform list are allowed. This requirement helps to prevent an open redirect attack against your project API + /// + [QueryParameter("failure")] + public string? FailureUri { get; set; } + + /// + /// A list of custom OAuth2 scopes. Check each provider internal docs for a list of supported scopes. Maximum of 100 scopes are allowed, each 4096 characters long + /// + [QueryParameter("scopes[]")] + public List? Scopes { get; set; } + + protected override string Path => "/account/tokens/oauth2/{provider}"; +} diff --git a/src/PinguApps.Appwrite.Shared/Requests/QueryParamBaseRequest.cs b/src/PinguApps.Appwrite.Shared/Requests/QueryParamBaseRequest.cs new file mode 100644 index 00000000..2d427b46 --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Requests/QueryParamBaseRequest.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using FluentValidation; +using PinguApps.Appwrite.Shared.Attributes; + +namespace PinguApps.Appwrite.Shared.Requests; +public abstract class QueryParamBaseRequest : BaseRequest + where TRequest : class + where TValidator : IValidator, new() +{ + public Uri BuildUri(string endpoint, string? projectId) + { + var endpointUri = new Uri(endpoint); + var endpointPath = endpointUri.AbsolutePath; + + var path = Path; + + var urlReplacementProperties = GetType() + .GetProperties() + .Where(x => Attribute.IsDefined(x, typeof(UrlReplacementAttribute))); + + foreach (var property in urlReplacementProperties) + { + var value = property.GetValue(this); + + if (value is string strValue) + { + var attribute = property.GetCustomAttribute(); + + path = path.Replace(attribute.Pattern, strValue); + } + } + + var combinedPath = CombinePaths(endpointPath, path); + + var builder = new UriBuilder(endpoint) + { + Path = combinedPath + }; + + var queryProperties = GetType() + .GetProperties() + .Where(x => Attribute.IsDefined(x, typeof(QueryParameterAttribute))); + + var queries = new List(); + + if (projectId is not null) + { + queries.Add($"project={projectId}"); + } + + foreach (var property in queryProperties) + { + var value = property.GetValue(this); + + if (value is null) + continue; + + var attribute = property.GetCustomAttribute(); + + if (value is IEnumerable values && value is not string) + { + foreach (var item in values) + { + queries.Add($"{attribute.Key}={Uri.EscapeDataString(item.ToString())}"); + } + } + else + { + queries.Add($"{attribute.Key}={Uri.EscapeDataString(value.ToString())}"); + } + } + + if (queries.Count > 0) + { + builder.Query = string.Join("&", queries); + } + + return builder.Uri; + } + + static string CombinePaths(string urlPath, string existingPath) + { + if (urlPath == "/") + { + return existingPath; + } + + if (!urlPath.EndsWith('/')) + { + urlPath += "/"; + } + + if (existingPath.StartsWith('/')) + { + existingPath = existingPath.TrimStart('/'); + } + + return urlPath + existingPath; + } + + protected abstract string Path { get; } +} diff --git a/src/PinguApps.Appwrite.Shared/Requests/Validators/CreateOauth2SessionRequestValidator.cs b/src/PinguApps.Appwrite.Shared/Requests/Validators/CreateOauth2SessionRequestValidator.cs new file mode 100644 index 00000000..26789b4c --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Requests/Validators/CreateOauth2SessionRequestValidator.cs @@ -0,0 +1,18 @@ +using System; +using FluentValidation; + +namespace PinguApps.Appwrite.Shared.Requests.Validators; +public class CreateOauth2SessionRequestValidator : AbstractValidator +{ + public CreateOauth2SessionRequestValidator() + { + RuleFor(x => x.Provider).NotEmpty().Must(x => string.Equals(x, x.ToLower(), StringComparison.Ordinal)).WithMessage("Provider must be all lower case."); + RuleFor(x => x.SuccessUri).Must(uri => Uri.TryCreate(uri, UriKind.Absolute, out _)).When(x => x.SuccessUri is not null); + RuleFor(x => x.FailureUri).Must(uri => Uri.TryCreate(uri, UriKind.Absolute, out _)).When(x => x.FailureUri is not null); + RuleFor(x => x.Scopes) + .Must(x => x!.Count <= 100).WithMessage("You can have a maximum of 100 scopes") + .Must(x => x!.Count > 0).WithMessage("You must have more than 0 scopes if passing a non null value") + .When(x => x.Scopes is not null); + RuleForEach(x => x.Scopes).MaximumLength(4096); + } +} diff --git a/src/PinguApps.Appwrite.Shared/Responses/CreateOauth2Session.cs b/src/PinguApps.Appwrite.Shared/Responses/CreateOauth2Session.cs new file mode 100644 index 00000000..b7ec6114 --- /dev/null +++ b/src/PinguApps.Appwrite.Shared/Responses/CreateOauth2Session.cs @@ -0,0 +1,11 @@ +using System.Text.Json.Serialization; + +namespace PinguApps.Appwrite.Shared.Responses; + +/// +/// A Create Oauth2 Session object +/// +/// The Uri that you should redirect the user towards to begin Oauth2 authentication +public record CreateOauth2Session( + [property: JsonPropertyName("uri")] string Uri +); diff --git a/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.CreateOauth2Session.cs b/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.CreateOauth2Session.cs new file mode 100644 index 00000000..67aa69d1 --- /dev/null +++ b/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.CreateOauth2Session.cs @@ -0,0 +1,38 @@ +using PinguApps.Appwrite.Shared.Requests; + +namespace PinguApps.Appwrite.Client.Tests.Clients.Account; +public partial class AccountClientTests +{ + [Fact] + public void CreateOauth2Session_ShouldReturnSuccess_WhenApiCallSucceeds() + { + // Arrange + var request = new CreateOauth2SessionRequest() + { + Provider = "google" + }; + + // Act + var result = _appwriteClient.Account.CreateOauth2Session(request); + + // Assert + Assert.True(result.Success); + } + + [Fact] + public void CreateOauth2Session_ShouldReturnErrorResponse_WhenExceptionOccurs() + { + // Arrange + var request = new CreateOauth2SessionRequest() + { + Provider = "" + }; + + // Act + var result = _appwriteClient.Account.CreateOauth2Session(request); + + // Assert + Assert.False(result.Success); + Assert.True(result.IsInternalError); + } +} diff --git a/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.cs b/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.cs index c42858a7..545e960f 100644 --- a/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.cs +++ b/tests/PinguApps.Appwrite.Client.Tests/Clients/Account/AccountClientTests.cs @@ -2,6 +2,7 @@ using Moq; using PinguApps.Appwrite.Client.Clients; using PinguApps.Appwrite.Client.Internals; +using PinguApps.Appwrite.Shared; using PinguApps.Appwrite.Shared.Tests; using Refit; using RichardSzalay.MockHttp; @@ -35,7 +36,7 @@ public void SetSession_UpdatesSession() var mockAccountApi = new Mock(); sc.AddSingleton(mockAccountApi.Object); var sp = sc.BuildServiceProvider(); - var accountClient = new AccountClient(sp); + var accountClient = new AccountClient(sp, new Config(Constants.Endpoint, Constants.ProjectId)); var sessionAware = accountClient as ISessionAware; // Act diff --git a/tests/PinguApps.Appwrite.Shared.Tests/ConfigTests.cs b/tests/PinguApps.Appwrite.Shared.Tests/ConfigTests.cs new file mode 100644 index 00000000..c9317bd2 --- /dev/null +++ b/tests/PinguApps.Appwrite.Shared.Tests/ConfigTests.cs @@ -0,0 +1,18 @@ +namespace PinguApps.Appwrite.Shared.Tests; +public class ConfigTests +{ + [Fact] + public void Config_ShouldInitializeProperties() + { + // Arrange + var endpoint = "https://example.com"; + var projectId = "project123"; + + // Act + var config = new Config(endpoint, projectId); + + // Assert + Assert.Equal(endpoint, config.Endpoint); + Assert.Equal(projectId, config.ProjectId); + } +} diff --git a/tests/PinguApps.Appwrite.Shared.Tests/Requests/CreateOauth2SessionRequestTests.cs b/tests/PinguApps.Appwrite.Shared.Tests/Requests/CreateOauth2SessionRequestTests.cs new file mode 100644 index 00000000..943833d2 --- /dev/null +++ b/tests/PinguApps.Appwrite.Shared.Tests/Requests/CreateOauth2SessionRequestTests.cs @@ -0,0 +1,145 @@ +using FluentValidation; +using PinguApps.Appwrite.Shared.Requests; + +namespace PinguApps.Appwrite.Shared.Tests.Requests; +public class CreateOauth2SessionRequestTests +{ + public class TestableCreateOauth2SessionRequest : CreateOauth2SessionRequest + { + public string ExposedPath => Path; + } + + [Fact] + public void Constructor_InitializesWithExpectedValues() + { + // Arrange & Act + var request = new TestableCreateOauth2SessionRequest(); + + // Assert + Assert.Equal(string.Empty, request.Provider); + Assert.Null(request.SuccessUri); + Assert.Null(request.FailureUri); + Assert.Null(request.Scopes); + Assert.Equal("/account/tokens/oauth2/{provider}", request.ExposedPath); + } + + [Fact] + public void Properties_CanBeSet() + { + // Arrange + var provider = "google"; + var success = "https://example.com/success"; + var failure = "https://example.com/failure"; + var scopes = new List + { + "scope1", + "scope2" + }; + + var request = new CreateOauth2SessionRequest(); + + // Act + request.Provider = provider; + request.SuccessUri = success; + request.FailureUri = failure; + request.Scopes = scopes; + + // Assert + Assert.Equal(provider, request.Provider); + Assert.Equal(success, request.SuccessUri); + Assert.Equal(failure, request.FailureUri); + Assert.Equal(scopes, request.Scopes); + } + + public static IEnumerable GetValidData() + { + yield return new object?[] { "google", null, null, null }; + yield return new object?[] { "google", "https://example.com/success", "https://example.com/failure", new List { "scope1" } }; + } + + [Theory] + [MemberData(nameof(GetValidData))] + public void IsValid_WithValidData_ReturnsTrue(string provider, string? success, string? failure, List scopes) + { + // Arrange + var request = new CreateOauth2SessionRequest + { + Provider = provider, + SuccessUri = success, + FailureUri = failure, + Scopes = scopes + }; + + // Act + var isValid = request.IsValid(); + + var validation = request.Validate(); + + // Assert + Assert.True(isValid); + } + + public static IEnumerable GetInvalidData() + { + yield return new object?[] { "", null, null, null }; + yield return new object?[] { "Google", null, null, null }; + yield return new object?[] { "google", "", null, null }; + yield return new object?[] { "google", "not a url", null, null }; + yield return new object?[] { "google", null, "", null }; + yield return new object?[] { "google", null, "not a url", null }; + yield return new object?[] { "google", null, null, new List() }; + yield return new object?[] { "google", null, null, Enumerable.Range(0, 101).Select(x => x.ToString()).ToList() }; + yield return new object?[] { "google", null, null, new List { new('a', 4097) } }; + } + + [Theory] + [MemberData(nameof(GetInvalidData))] + public void IsValid_WithInvalidData_ReturnsFalse(string? provider, string? success, string? failure, List scopes) + { + // Arrange + var request = new CreateOauth2SessionRequest + { + Provider = provider!, + SuccessUri = success, + FailureUri = failure, + Scopes = scopes + }; + + // Act + var isValid = request.IsValid(); + + // Assert + Assert.False(isValid); + } + + [Fact] + public void Validate_WithThrowOnFailuresTrue_ThrowsValidationExceptionOnFailure() + { + // Arrange + var request = new CreateOauth2SessionRequest + { + Provider = "", + SuccessUri = "not a url" + }; + + // Assert + Assert.Throws(() => request.Validate(true)); + } + + [Fact] + public void Validate_WithThrowOnFailuresFalse_ReturnsInvalidResultOnFailure() + { + // Arrange + var request = new CreateOauth2SessionRequest + { + Provider = "", + SuccessUri = "not a url" + }; + + // Act + var result = request.Validate(false); + + // Assert + Assert.False(result.IsValid); + } +} diff --git a/tests/PinguApps.Appwrite.Shared.Tests/Requests/QueryParamBaseRequestTests.cs b/tests/PinguApps.Appwrite.Shared.Tests/Requests/QueryParamBaseRequestTests.cs new file mode 100644 index 00000000..ab4d35cc --- /dev/null +++ b/tests/PinguApps.Appwrite.Shared.Tests/Requests/QueryParamBaseRequestTests.cs @@ -0,0 +1,163 @@ +using FluentValidation; +using PinguApps.Appwrite.Shared.Attributes; +using PinguApps.Appwrite.Shared.Requests; + +namespace PinguApps.Appwrite.Shared.Tests.Requests; +public class QueryParamBaseRequestTests +{ + private class TestRequest : QueryParamBaseRequest + { + [UrlReplacement("{id}")] + public string Id { get; set; } = string.Empty; + + [QueryParameter("name")] + public string? Name { get; set; } + + [QueryParameter("tags")] + public List? Tags { get; set; } + + protected override string Path => "/api/resource/{id}"; + } + + private class TestValidator : AbstractValidator + { + } + + [Fact] + public void BuildUri_ShouldReplaceUrlPlaceholders() + { + // Arrange + var request = new TestRequest + { + Id = "123" + }; + + var endpoint = "https://example.com"; + var expectedUri = new Uri("https://example.com/api/resource/123"); + + // Act + var result = request.BuildUri(endpoint, null); + + // Assert + Assert.Equal(expectedUri, result); + } + + [Fact] + public void BuildUri_ShouldAddQueryParameters() + { + // Arrange + var request = new TestRequest + { + Id = "123", + Name = "test", + Tags = ["tag1", "tag2"] + }; + + var endpoint = "https://example.com"; + var expectedUri = new Uri("https://example.com/api/resource/123?name=test&tags=tag1&tags=tag2"); + + // Act + var result = request.BuildUri(endpoint, null); + + // Assert + Assert.Equal(expectedUri, result); + } + + [Fact] + public void BuildUri_ShouldAddProjectIdToQueryParameters() + { + // Arrange + var request = new TestRequest + { + Id = "123" + }; + + var endpoint = "https://example.com"; + var projectId = "project123"; + var expectedUri = new Uri("https://example.com/api/resource/123?project=project123"); + + // Act + var result = request.BuildUri(endpoint, projectId); + + // Assert + Assert.Equal(expectedUri, result); + } + + [Fact] + public void BuildUri_ShouldHandleNullQueryParameterValues() + { + // Arrange + var request = new TestRequest + { + Id = "123", + Name = null + }; + + var endpoint = "https://example.com"; + var expectedUri = new Uri("https://example.com/api/resource/123"); + + // Act + var result = request.BuildUri(endpoint, null); + + // Assert + Assert.Equal(expectedUri, result); + } + + [Fact] + public void BuildUri_ShouldHandleEmptyEndpointPath() + { + // Arrange + var request = new TestRequest + { + Id = "123" + }; + + var endpoint = "https://example.com/"; + var expectedUri = new Uri("https://example.com/api/resource/123"); + + // Act + var result = request.BuildUri(endpoint, null); + + // Assert + Assert.Equal(expectedUri, result); + } + + [Fact] + public void BuildUri_ShouldHandleComplexQueryParameterValues() + { + // Arrange + var request = new TestRequest + { + Id = "123", + Tags = ["tag1", "tag2"] + }; + + var endpoint = "https://example.com"; + var expectedUri = new Uri("https://example.com/api/resource/123?tags=tag1&tags=tag2"); + + // Act + var result = request.BuildUri(endpoint, null); + + // Assert + Assert.Equal(expectedUri, result); + } + + [Fact] + public void BuildUri_ShouldHandleExistingPathIdEndpoint() + { + // Arrange + var request = new TestRequest + { + Id = "123" + }; + + var endpoint = "https://example.com/existing/path"; + var expectedUri = new Uri("https://example.com/existing/path/api/resource/123"); + + // Act + var result = request.BuildUri(endpoint, null); + + // Assert + Assert.Equal(expectedUri, result); + } +} diff --git a/tests/PinguApps.Appwrite.Shared.Tests/Responses/CreateOauth2SessionTests.cs b/tests/PinguApps.Appwrite.Shared.Tests/Responses/CreateOauth2SessionTests.cs new file mode 100644 index 00000000..767aff38 --- /dev/null +++ b/tests/PinguApps.Appwrite.Shared.Tests/Responses/CreateOauth2SessionTests.cs @@ -0,0 +1,18 @@ +using PinguApps.Appwrite.Shared.Responses; + +namespace PinguApps.Appwrite.Shared.Tests.Responses; +public class CreateOauth2SessionTests +{ + [Fact] + public void Constructor_AssignsPropertiesCorrectly() + { + // Arrange + var uri = "https://exmaple.com"; + + // Act + var response = new CreateOauth2Session(uri); + + // Assert + Assert.Equal(uri, response.Uri); + } +}