diff --git a/Directory.Packages.props b/Directory.Packages.props index b8a0523..d98075e 100755 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -30,6 +30,7 @@ + diff --git a/PlaceApi.sln b/PlaceApi.sln index 56ba210..c8abee1 100755 --- a/PlaceApi.sln +++ b/PlaceApi.sln @@ -58,6 +58,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Place.Core.Logging", "src\C EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Place.Core.Database", "src\Common\Place.Core.Database\Place.Core.Database.csproj", "{FB0782E5-FA12-4030-B382-0311E004C166}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Place.Api.Integration.Tests", "tests\Place.Api.Integration.Tests\Place.Api.Integration.Tests.csproj", "{967AC2B2-7C7A-4037-B2F6-CF9792A69588}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -81,6 +83,7 @@ Global {2AF91DCD-AABF-4EF0-844F-2319A1FAA4B5} = {25654B5F-D4E9-4019-B307-8759C38E94A8} {C37B0761-0FF0-46F4-932B-B2C42C926E73} = {25654B5F-D4E9-4019-B307-8759C38E94A8} {FB0782E5-FA12-4030-B382-0311E004C166} = {25654B5F-D4E9-4019-B307-8759C38E94A8} + {967AC2B2-7C7A-4037-B2F6-CF9792A69588} = {5B48E2F5-D7CC-48EA-94FE-A2F132650C8F} EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution {1DFC8537-6B29-4BB5-8449-1910496DB479}.Debug|Any CPU.ActiveCfg = Debug|Any CPU @@ -141,5 +144,9 @@ Global {FB0782E5-FA12-4030-B382-0311E004C166}.Debug|Any CPU.Build.0 = Debug|Any CPU {FB0782E5-FA12-4030-B382-0311E004C166}.Release|Any CPU.ActiveCfg = Release|Any CPU {FB0782E5-FA12-4030-B382-0311E004C166}.Release|Any CPU.Build.0 = Release|Any CPU + {967AC2B2-7C7A-4037-B2F6-CF9792A69588}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {967AC2B2-7C7A-4037-B2F6-CF9792A69588}.Debug|Any CPU.Build.0 = Debug|Any CPU + {967AC2B2-7C7A-4037-B2F6-CF9792A69588}.Release|Any CPU.ActiveCfg = Release|Any CPU + {967AC2B2-7C7A-4037-B2F6-CF9792A69588}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection EndGlobal diff --git a/global.json b/global.json index a576a36..9cb0e34 100755 --- a/global.json +++ b/global.json @@ -1,6 +1,6 @@ { "sdk": { - "version": "8.0.201", + "version": "8.0.401", "rollForward": "major", "allowPrerelease": true } diff --git a/src/Place.Api.Profile/Apis/V1/Endpoints/GetPersonalInfo.cs b/src/Place.Api.Profile/Apis/V1/Endpoints/GetPersonalInfo.cs index 278c560..fd5e92a 100755 --- a/src/Place.Api.Profile/Apis/V1/Endpoints/GetPersonalInfo.cs +++ b/src/Place.Api.Profile/Apis/V1/Endpoints/GetPersonalInfo.cs @@ -47,7 +47,21 @@ public async Task< if (vm is not null) { - return TypedResults.Ok(new PersonalInformationResponse(vm)); + return TypedResults.Ok( + new PersonalInformationResponse + { + FirstName = vm.FirstName, + LastName = vm.LastName, + Email = vm.Email, + PhoneNumber = vm.PhoneNumber, + Street = vm.Street, + City = vm.City, + ZipCode = vm.ZipCode, + Country = vm.Country, + Gender = vm.Gender, + FormattedAddress = vm.FormattedAddress, + } + ); } ProblemDetails problem = diff --git a/src/Place.Api.Profile/Apis/V1/Responses/PersonalInformationResponse.cs b/src/Place.Api.Profile/Apis/V1/Responses/PersonalInformationResponse.cs index f6b728f..b8b50e1 100755 --- a/src/Place.Api.Profile/Apis/V1/Responses/PersonalInformationResponse.cs +++ b/src/Place.Api.Profile/Apis/V1/Responses/PersonalInformationResponse.cs @@ -4,28 +4,14 @@ namespace Place.Api.Profile.Apis.V1.Responses; public record PersonalInformationResponse { - public string? FirstName { get; } - public string? LastName { get; } - public string Email { get; } - public string? PhoneNumber { get; } - public string? Street { get; } - public string? City { get; } - public string? ZipCode { get; } - public string? Country { get; } - public string? Gender { get; } - public string FormattedAddress { get; } - - public PersonalInformationResponse(PersonalInformationViewModel vm) - { - FirstName = vm.FirstName; - LastName = vm.LastName; - Email = vm.Email; - PhoneNumber = vm.PhoneNumber; - Street = vm.Street; - City = vm.City; - ZipCode = vm.ZipCode; - Country = vm.Country; - Gender = vm.Gender; - FormattedAddress = vm.FormattedAddress; - } + public string? FirstName { get; set; } + public string? LastName { get; set; } + public string Email { get; set; } = null!; + public string? PhoneNumber { get; set; } + public string? Street { get; set; } + public string? City { get; set; } + public string? ZipCode { get; set; } + public string? Country { get; set; } + public string? Gender { get; set; } + public string FormattedAddress { get; set; } = null!; } diff --git a/src/Place.Api.Profile/IAssemblyMarker.cs b/src/Place.Api.Profile/IAssemblyMarker.cs new file mode 100755 index 0000000..6b8a5ba --- /dev/null +++ b/src/Place.Api.Profile/IAssemblyMarker.cs @@ -0,0 +1,3 @@ +namespace Place.Api.Profile; + +public interface IAssemblyMarker { } diff --git a/tests/Place.Api.Integration.Tests/Apis/Extensions/AddressValidationExtensions.cs b/tests/Place.Api.Integration.Tests/Apis/Extensions/AddressValidationExtensions.cs new file mode 100755 index 0000000..7ccd3eb --- /dev/null +++ b/tests/Place.Api.Integration.Tests/Apis/Extensions/AddressValidationExtensions.cs @@ -0,0 +1,75 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Execution; +using Place.Api.Profile.Apis.V1.Responses; +using Place.Api.Profile.Infrastructure.Persistence.EF.Models; + +namespace Place.Api.Integration.Tests.Apis.Extensions; + +public static class AddressValidationExtensions +{ + public static Task ValidateAddress( + this PersonalInformationResponse response, + ProfileReadModel profile + ) + { + using AssertionScope scope = new(); + + response.FormattedAddress.Should().NotBeNull("FormattedAddress should never be null"); + + if (IsCompleteAddress(profile)) + { + ValidateCompleteAddress(response.FormattedAddress, profile); + return Task.CompletedTask; + } + + ValidatePartialAddress(response.FormattedAddress, profile); + return Task.CompletedTask; + } + + private static bool IsCompleteAddress(ProfileReadModel profile) => + !string.IsNullOrEmpty(profile.Street) + && !string.IsNullOrEmpty(profile.City) + && !string.IsNullOrEmpty(profile.ZipCode) + && !string.IsNullOrEmpty(profile.Country); + + private static void ValidateCompleteAddress(string formattedAddress, ProfileReadModel profile) + { + string expectedAddress = + $"{profile.Street}, {profile.ZipCode} {profile.City}, {profile.Country}"; + + formattedAddress + .Should() + .Be(expectedAddress) + .And.Contain(profile.Street!) + .And.Contain(profile.ZipCode!) + .And.Contain(profile.City!) + .And.Contain(profile.Country!); + + ValidateAddressOrder(formattedAddress, profile); + } + + private static void ValidateAddressOrder(string formattedAddress, ProfileReadModel profile) + { + int streetIndex = formattedAddress.IndexOf(profile.Street!, StringComparison.Ordinal); + int zipIndex = formattedAddress.IndexOf(profile.ZipCode!, StringComparison.Ordinal); + int cityIndex = formattedAddress.IndexOf(profile.City!, StringComparison.Ordinal); + int countryIndex = formattedAddress.IndexOf(profile.Country!, StringComparison.Ordinal); + + streetIndex.Should().BeLessThan(zipIndex, "Street should come before zip code"); + zipIndex.Should().BeLessThan(countryIndex, "Zip code should come before country"); + cityIndex.Should().BeLessThan(countryIndex, "City should come before country"); + } + + private static void ValidatePartialAddress(string formattedAddress, ProfileReadModel profile) + { + string?[] components = [profile.Street, profile.City, profile.ZipCode, profile.Country]; + + foreach (string? component in components.Where(c => !string.IsNullOrEmpty(c))) + { + formattedAddress.Should().Contain(component!); + } + } +} diff --git a/tests/Place.Api.Integration.Tests/Apis/Extensions/PersonalInformationAssertions.cs b/tests/Place.Api.Integration.Tests/Apis/Extensions/PersonalInformationAssertions.cs new file mode 100755 index 0000000..630be31 --- /dev/null +++ b/tests/Place.Api.Integration.Tests/Apis/Extensions/PersonalInformationAssertions.cs @@ -0,0 +1,160 @@ +using System.Net; +using System.Net.Http; +using System.Threading.Tasks; +using FluentAssertions; +using FluentAssertions.Execution; +using Place.Api.Profile.Apis.V1.Responses; +using Place.Api.Profile.Domain.Profile; +using Place.Api.Profile.Infrastructure.Persistence.EF.Models; + +namespace Place.Api.Integration.Tests.Apis.Extensions; + +public static class PersonalInformationAssertions +{ + public static Task ShouldReturnValidProfileAsync( + this (HttpResponseMessage Response, PersonalInformationResponse? Content) result, + ProfileReadModel expectedProfile + ) + { + using AssertionScope scope = new(); + + ValidateHttpResponse(result.Response); + ValidateContent(result.Content, expectedProfile); + ValidateContactInformation(result.Content!, expectedProfile); + result.Content!.ValidateAddress(expectedProfile); + + return Task.CompletedTask; + } + + public static Task ShouldReturnPartialAddressAsync( + this (HttpResponseMessage Response, PersonalInformationResponse? Content) result, + string? expectedCity, + string? expectedCountry, + string expectedFormattedAddress + ) + { + using AssertionScope scope = new(); + + ValidateHttpResponse(result.Response); + ValidatePartialAddress( + result.Content!, + expectedCity, + expectedCountry, + expectedFormattedAddress + ); + + return Task.CompletedTask; + } + + private static void ValidateHttpResponse(HttpResponseMessage response) + { + response.Should().NotBeNull(); + response + .StatusCode.Should() + .Be(HttpStatusCode.OK, "a valid profile should return OK status"); + response.Content.Headers.ContentType?.MediaType.Should().Be("application/json"); + } + + private static void ValidateContent( + PersonalInformationResponse? content, + ProfileReadModel expected + ) + { + content.Should().NotBeNull("response content should not be null for a valid profile"); + + content!.FirstName.Should().Be(expected.FirstName, "FirstName should match exactly"); + content.LastName.Should().Be(expected.LastName, "LastName should match exactly"); + content.Street.Should().Be(expected.Street, "Street should match exactly"); + content.City.Should().Be(expected.City, "City should match exactly"); + content.ZipCode.Should().Be(expected.ZipCode, "ZipCode should match exactly"); + content.Country.Should().Be(expected.Country, "Country should match exactly"); + + ValidateNonEmptyFields(content, expected); + } + + private static void ValidateContactInformation( + PersonalInformationResponse content, + ProfileReadModel expected + ) + { + ValidateEmail(content.Email); + ValidatePhoneNumber(content.PhoneNumber); + ValidateGender(content.Gender, expected.Gender); + } + + private static void ValidateNonEmptyFields( + PersonalInformationResponse content, + ProfileReadModel expected + ) + { + if (expected.FirstName != null) + content.FirstName.Should().NotBeEmpty("FirstName should not be empty when provided"); + + if (expected.LastName != null) + content.LastName.Should().NotBeEmpty("LastName should not be empty when provided"); + + if (expected.Street != null) + content.Street.Should().NotBeEmpty("Street should not be empty when provided"); + } + + private static void ValidateEmail(string email) + { + email + .Should() + .NotBeNullOrWhiteSpace("Email is required") + .And.Contain("@", "Email should be in valid format") + .And.Contain(".", "Email should be in valid format"); + } + + private static void ValidatePhoneNumber(string? phoneNumber) + { + if (phoneNumber != null) + { + phoneNumber + .Should() + .StartWith("+", "Phone number should start with country code") + .And.HaveLength(12, "Phone number should be in international format"); + } + } + + private static void ValidateGender(string? actual, Gender? expected) + { + if (expected.HasValue) + { + actual.Should().NotBeNull().And.Be(expected.ToString(), "Gender should match exactly"); + } + else + { + actual.Should().BeNull("Gender should be null when not provided"); + } + } + + private static void ValidatePartialAddress( + PersonalInformationResponse content, + string? expectedCity, + string? expectedCountry, + string expectedFormattedAddress + ) + { + content.City.Should().Be(expectedCity); + content.Country.Should().Be(expectedCountry); + content.FormattedAddress.Should().Be(expectedFormattedAddress); + + if (expectedCity == null) + content.City.Should().BeNull("City should be null when not provided"); + + if (expectedCountry == null) + content.Country.Should().BeNull("Country should be null when not provided"); + + content.FormattedAddress.Should().NotBeNull("FormattedAddress should never be null"); + + if (string.IsNullOrWhiteSpace(expectedCity) && string.IsNullOrWhiteSpace(expectedCountry)) + { + content + .FormattedAddress.Should() + .BeEmpty( + "FormattedAddress should be empty when no address components are provided" + ); + } + } +} diff --git a/tests/Place.Api.Integration.Tests/Apis/V1/Endpoints/GetPersonalInfo/GetPersonnalInfoTests.cs b/tests/Place.Api.Integration.Tests/Apis/V1/Endpoints/GetPersonalInfo/GetPersonnalInfoTests.cs new file mode 100755 index 0000000..fd54c01 --- /dev/null +++ b/tests/Place.Api.Integration.Tests/Apis/V1/Endpoints/GetPersonalInfo/GetPersonnalInfoTests.cs @@ -0,0 +1,100 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; +using Place.Api.Integration.Tests.Apis.Extensions; +using Place.Api.Integration.Tests.Common; +using Place.Api.Profile.Apis.V1.Responses; +using Place.Api.Profile.Domain.Profile; +using Place.Api.Profile.Infrastructure.Persistence.EF.Models; + +namespace Place.Api.Integration.Tests.Apis.V1.Endpoints.GetPersonalInfo; + +[Collection(nameof(ProfileApiCollection))] +[Trait("Category", "PersonalInformation")] +public sealed class GetPersonalInformationTests(ProfileWebAppFactory factory) + : IntegrationTest(factory) +{ + [Theory] + [MemberData( + nameof(TestDataFactory.ValidProfileTestCases), + MemberType = typeof(TestDataFactory) + )] + public async Task GetPersonalInformation_WithValidProfile_ShouldReturnExpectedData( + ProfileTestCase testCase + ) + { + // Arrange + ProfileReadModel profile = new ProfileTestDataBuilder() + .WithBasicInfo(testCase) + .WithAddress("123 Main St", "Paris", "75001", "France") + .WithGender(Gender.Male) + .Build(); + + ProfileReadModel seededProfile = await this.Seeder.SeedProfile(profile); + + // Act + (HttpResponseMessage Response, PersonalInformationResponse? Content) result = + await this.Client.GetPersonalInformation(seededProfile.Id); + + // Assert + await result.ShouldReturnValidProfileAsync(profile); + } + + [Theory] + [MemberData( + nameof(TestDataFactory.AddressFormatTestCases), + MemberType = typeof(TestDataFactory) + )] + public async Task GetPersonalInformation_WithPartialAddress_ShouldFormatAddressCorrectly( + string? city, + string? country, + string expectedAddress + ) + { + // Arrange + ProfileReadModel profile = new ProfileTestDataBuilder() + .WithBasicInfo(new ProfileTestCase("Test", "User", "test@example.com", "+33612345678")) + .Build(); + + profile.City = city; + profile.Country = country; + + ProfileReadModel seededProfile = await this.Seeder.SeedProfile(profile); + + // Act + (HttpResponseMessage Response, PersonalInformationResponse? Content) result = + await this.Client.GetPersonalInformation(seededProfile.Id); + + // Assert + await result.ShouldReturnPartialAddressAsync(city, country, expectedAddress); + } + + [Fact] + public async Task GetPersonalInformation_WithPrefilledProfile_ShouldReturnCorrectData() + { + // Arrange + ProfileReadModel seededProfile = await this.Seeder.SeedProfile( + TestDataFactory.CreateDefaultProfile() + ); + + // Act + (HttpResponseMessage Response, PersonalInformationResponse? Content) result = + await this.Client.GetPersonalInformation(seededProfile.Id); + + // Assert + await result.ShouldReturnValidProfileAsync(seededProfile); + } + + [Fact] + public async Task GetPersonalInformation_WithNonExistentId_ShouldReturnNotFound() + { + // Arrange + Guid nonExistentId = Guid.NewGuid(); + + // Act + (HttpResponseMessage response, _) = await this.Client.GetPersonalInformation(nonExistentId); + + // Assert + await response.ShouldBeNotFoundAsync($"Profile with ID {nonExistentId} was not found."); + } +} diff --git a/tests/Place.Api.Integration.Tests/Common/HttpClientBase.cs b/tests/Place.Api.Integration.Tests/Common/HttpClientBase.cs new file mode 100755 index 0000000..a162c2a --- /dev/null +++ b/tests/Place.Api.Integration.Tests/Common/HttpClientBase.cs @@ -0,0 +1,78 @@ +using System; +using System.Net.Http; +using System.Net.Http.Json; +using System.Text.Json; +using System.Threading.Tasks; +using Place.Api.Profile.Apis.V1.Responses; + +namespace Place.Api.Integration.Tests.Common; + +public abstract class BaseHttpClient +{ + private readonly HttpClient _httpClient; + protected static readonly JsonSerializerOptions JsonOptions = + new() + { + PropertyNameCaseInsensitive = true, + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + }; + + protected BaseHttpClient(HttpClient httpClient) + { + _httpClient = httpClient; + } + + protected async Task<( + HttpResponseMessage Response, + TResponse? Content + )> GetWithResponseAsync(string url) + where TResponse : class + { + HttpResponseMessage response = await _httpClient.GetAsync(url); + TResponse? content = null; + + if (response.IsSuccessStatusCode) + { + string jsonString = await response.Content.ReadAsStringAsync(); + content = JsonSerializer.Deserialize(jsonString, JsonOptions); + } + + return (response, content); + } + + protected Task PostAsJsonAsync(string url, TRequest request) + where TRequest : class + { + return _httpClient.PostAsJsonAsync(url, request, JsonOptions); + } + + protected async Task DeserializeResponseAsync( + HttpResponseMessage response + ) + where TResponse : class + { + if (!response.IsSuccessStatusCode) + { + return null; + } + + string jsonString = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(jsonString, JsonOptions); + } +} + +public class ProfileHttpClient : BaseHttpClient +{ + public ProfileHttpClient(HttpClient httpClient) + : base(httpClient) { } + + public Task<( + HttpResponseMessage Response, + PersonalInformationResponse? Content + )> GetPersonalInformation(Guid profileId) + { + return GetWithResponseAsync( + $"/api/v1/profiles/{profileId}/personal-information" + ); + } +} diff --git a/tests/Place.Api.Integration.Tests/Common/HttpResponseAssertions.cs b/tests/Place.Api.Integration.Tests/Common/HttpResponseAssertions.cs new file mode 100755 index 0000000..f9977d1 --- /dev/null +++ b/tests/Place.Api.Integration.Tests/Common/HttpResponseAssertions.cs @@ -0,0 +1,58 @@ +using System.Net; +using System.Net.Http; +using System.Text.Json; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.AspNetCore.Mvc; + +namespace Place.Api.Integration.Tests.Common; + +public static class HttpResponseAssertions +{ + private static readonly JsonSerializerOptions JsonOptions = + new() { PropertyNameCaseInsensitive = true }; + + public static async Task ShouldBeNotFoundAsync( + this HttpResponseMessage response, + string expectedDetail + ) + { + response.StatusCode.Should().Be(HttpStatusCode.NotFound); + + ProblemDetails? problemDetails = await DeserializeResponseAsync(response); + + problemDetails.Should().NotBeNull(); + problemDetails!.Status.Should().Be((int)HttpStatusCode.NotFound); + problemDetails.Title.Should().Be("Profile Not Found"); + problemDetails.Detail.Should().Be(expectedDetail); + problemDetails.Type.Should().Be("https://tools.ietf.org/html/rfc7231#section-6.5.4"); + } + + public static async Task ShouldBeBadRequestAsync( + this HttpResponseMessage response, + string expectedErrorKey, + string expectedErrorMessage + ) + { + response.StatusCode.Should().Be(HttpStatusCode.BadRequest); + + ValidationProblemDetails? problemDetails = + await DeserializeResponseAsync(response); + + problemDetails.Should().NotBeNull(); + problemDetails!.Status.Should().Be((int)HttpStatusCode.BadRequest); + problemDetails.Title.Should().Be("One or more validation errors occurred."); + problemDetails + .Errors.Should() + .ContainKey(expectedErrorKey) + .WhoseValue.Should() + .Contain(expectedErrorMessage); + } + + private static async Task DeserializeResponseAsync(HttpResponseMessage response) + where T : class + { + string content = await response.Content.ReadAsStringAsync(); + return JsonSerializer.Deserialize(content, JsonOptions); + } +} diff --git a/tests/Place.Api.Integration.Tests/Common/IntegrationTest.cs b/tests/Place.Api.Integration.Tests/Common/IntegrationTest.cs new file mode 100755 index 0000000..1ab9c9a --- /dev/null +++ b/tests/Place.Api.Integration.Tests/Common/IntegrationTest.cs @@ -0,0 +1,52 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; +using Microsoft.Extensions.DependencyInjection; + +namespace Place.Api.Integration.Tests.Common; + +[Collection(nameof(ProfileApiCollection))] +public abstract class IntegrationTest : IAsyncLifetime +{ + private readonly ProfileWebAppFactory _factory; + private readonly IServiceScope _scope; + + protected readonly HttpClient HttpClient; + protected readonly ProfileHttpClient Client; + protected readonly TestDataSeeder Seeder; + + protected IntegrationTest(ProfileWebAppFactory factory) + { + _factory = factory; + _scope = _factory.Services.CreateScope(); + + HttpClient = factory.CreateClient(); + Client = new ProfileHttpClient(HttpClient); + Seeder = _scope.ServiceProvider.GetRequiredService(); + } + + public virtual async Task InitializeAsync() + { + await _factory.ResetDatabaseAsync(); + } + + public virtual Task DisposeAsync() + { + _scope.Dispose(); + return Task.CompletedTask; + } + + protected async Task ExecuteInScopeAsync( + Func> action + ) + { + using IServiceScope scope = _factory.Services.CreateScope(); + return await action(scope.ServiceProvider); + } + + protected async Task ExecuteInScopeAsync(Func action) + { + using IServiceScope scope = _factory.Services.CreateScope(); + await action(scope.ServiceProvider); + } +} diff --git a/tests/Place.Api.Integration.Tests/Common/ProfileTestCase.cs b/tests/Place.Api.Integration.Tests/Common/ProfileTestCase.cs new file mode 100755 index 0000000..b9d1047 --- /dev/null +++ b/tests/Place.Api.Integration.Tests/Common/ProfileTestCase.cs @@ -0,0 +1,17 @@ +// ProfileTestCase.cs + +using Place.Api.Profile.Domain.Profile; + +namespace Place.Api.Integration.Tests.Common; + +public sealed record ProfileTestCase( + string FirstName, + string LastName, + string Email, + string PhoneNumber, + string? Street = null, + string? City = null, + string? ZipCode = null, + string? Country = null, + Gender? Gender = null +); diff --git a/tests/Place.Api.Integration.Tests/Common/ProfileTestDataBuilder.cs b/tests/Place.Api.Integration.Tests/Common/ProfileTestDataBuilder.cs new file mode 100755 index 0000000..1e3a01b --- /dev/null +++ b/tests/Place.Api.Integration.Tests/Common/ProfileTestDataBuilder.cs @@ -0,0 +1,46 @@ +using System; +using Place.Api.Profile.Domain.Profile; +using Place.Api.Profile.Infrastructure.Persistence.EF.Models; + +namespace Place.Api.Integration.Tests.Common; + +public sealed class ProfileTestDataBuilder +{ + private readonly ProfileReadModel _profile; + + public ProfileTestDataBuilder() + { + _profile = new ProfileReadModel { Id = Guid.NewGuid(), Email = "default@example.com" }; + } + + public ProfileTestDataBuilder WithBasicInfo(ProfileTestCase testCase) + { + _profile.FirstName = testCase.FirstName; + _profile.LastName = testCase.LastName; + _profile.Email = testCase.Email; + _profile.PhoneNumber = testCase.PhoneNumber; + return this; + } + + public ProfileTestDataBuilder WithAddress( + string street, + string city, + string zipCode, + string country + ) + { + _profile.Street = street; + _profile.City = city; + _profile.ZipCode = zipCode; + _profile.Country = country; + return this; + } + + public ProfileTestDataBuilder WithGender(Gender gender) + { + _profile.Gender = gender; + return this; + } + + public ProfileReadModel Build() => _profile; +} diff --git a/tests/Place.Api.Integration.Tests/Common/ProfileWebAppFactory.cs b/tests/Place.Api.Integration.Tests/Common/ProfileWebAppFactory.cs new file mode 100755 index 0000000..7af072b --- /dev/null +++ b/tests/Place.Api.Integration.Tests/Common/ProfileWebAppFactory.cs @@ -0,0 +1,80 @@ +using System.Threading.Tasks; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Npgsql; +using Place.Api.Profile; +using Place.Api.Profile.Infrastructure.Persistence.EF.Configurations; +using Respawn; +using Testcontainers.PostgreSql; + +namespace Place.Api.Integration.Tests.Common; + +[CollectionDefinition(nameof(ProfileApiCollection))] +public class ProfileApiCollection : ICollectionFixture { } + +public class ProfileWebAppFactory : WebApplicationFactory, IAsyncLifetime +{ + private readonly PostgreSqlContainer _dbContainer = default!; + private Respawner? _respawner = default!; + private readonly RespawnerOptions _respawnerOptions; + public string ConnectionString => _dbContainer.GetConnectionString(); + + public ProfileWebAppFactory() + { + _dbContainer = new PostgreSqlBuilder() + .WithImage("postgres:15.1") + .WithDatabase("PlaceApiIdentity") + .WithUsername("postgres") + .WithPassword("postgres") + .Build(); + + _respawnerOptions = new RespawnerOptions + { + DbAdapter = DbAdapter.Postgres, + SchemasToInclude = new[] { "public" }, + }; + } + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.ConfigureServices(services => + { + services.RemoveAll(typeof(DbContextOptions)); + + services.AddDbContext(options => + options.UseNpgsql(_dbContainer.GetConnectionString()) + ); + + services.AddScoped(); + }); + } + + public async Task ResetDatabaseAsync() + { + await using NpgsqlConnection connection = new(ConnectionString); + await connection.OpenAsync(); + + if (_respawner is null) + { + _respawner = await Respawner.CreateAsync(connection, _respawnerOptions); + } + + await _respawner.ResetAsync(connection); + } + + public async Task InitializeAsync() + { + await _dbContainer.StartAsync(); + using IServiceScope scope = Services.CreateScope(); + ProfileDbContext context = scope.ServiceProvider.GetRequiredService(); + await context.Database.MigrateAsync(); + } + + public new async Task DisposeAsync() + { + await _dbContainer.DisposeAsync(); + } +} diff --git a/tests/Place.Api.Integration.Tests/Common/TestDataFactory.cs b/tests/Place.Api.Integration.Tests/Common/TestDataFactory.cs new file mode 100755 index 0000000..3c3a5b2 --- /dev/null +++ b/tests/Place.Api.Integration.Tests/Common/TestDataFactory.cs @@ -0,0 +1,31 @@ +using Place.Api.Profile.Domain.Profile; +using Place.Api.Profile.Infrastructure.Persistence.EF.Models; + +namespace Place.Api.Integration.Tests.Common; + +public static class TestDataFactory +{ + public static TheoryData ValidProfileTestCases => + new() + { + new ProfileTestCase("Jean", "Dupont", "jean.dupont@example.com", "+33612345678"), + new ProfileTestCase("Marie", "Martin", "marie.martin@example.com", "+33687654321"), + }; + + public static TheoryData AddressFormatTestCases => + new() + { + { "Lyon", "France", "Lyon, France" }, + { "Paris", null, "Paris" }, + { null, "France", "France" }, + }; + + public static ProfileReadModel CreateDefaultProfile() => + new ProfileTestDataBuilder() + .WithBasicInfo( + new ProfileTestCase("John", "Doe", "john.doe@example.com", "+33612345678") + ) + .WithAddress("123 Main St", "Paris", "75001", "France") + .WithGender(Gender.Male) + .Build(); +} diff --git a/tests/Place.Api.Integration.Tests/Common/TestDataSeeder.cs b/tests/Place.Api.Integration.Tests/Common/TestDataSeeder.cs new file mode 100755 index 0000000..f2411d8 --- /dev/null +++ b/tests/Place.Api.Integration.Tests/Common/TestDataSeeder.cs @@ -0,0 +1,77 @@ +using System; +using System.Threading.Tasks; +using Microsoft.EntityFrameworkCore; +using Place.Api.Profile.Domain.Profile; +using Place.Api.Profile.Infrastructure.Persistence.EF.Configurations; +using Place.Api.Profile.Infrastructure.Persistence.EF.Models; + +namespace Place.Api.Integration.Tests.Common; + +public class TestDataSeeder(ProfileDbContext dbContext) +{ + public async Task SeedBasicProfile() + { + ProfileReadModel profile = + new() + { + Id = Guid.NewGuid(), + FirstName = "John", + LastName = "Doe", + Email = "john.doe@example.com", + PhoneNumber = "+33612345678", + Street = "123 Main St", + City = "Paris", + ZipCode = "75001", + Country = "France", + Gender = Gender.Male, + }; + + return await SeedProfile(profile); + } + + public async Task SeedProfile(ProfileReadModel profile) + { + dbContext.Profiles.Add(profile); + await dbContext.SaveChangesAsync(); + + return await dbContext.Profiles.AsNoTracking().FirstAsync(p => p.Id == profile.Id); + } + + public async Task SeedPartialProfile() + { + ProfileReadModel profile = + new() + { + Id = Guid.NewGuid(), + Email = "partial@example.com", + City = "Lyon", + Country = "France", + }; + + return await SeedProfile(profile); + } + + public async Task SeedMultipleProfiles(int count) + { + ProfileReadModel[] profiles = new ProfileReadModel[count]; + + for (int i = 0; i < count; i++) + { + profiles[i] = new ProfileReadModel + { + Id = Guid.NewGuid(), + FirstName = $"User{i}", + LastName = $"Test{i}", + Email = $"user{i}@example.com", + PhoneNumber = $"+3361234{i:D4}", + City = "Paris", + Country = "France", + }; + } + + dbContext.Profiles.AddRange(profiles); + await dbContext.SaveChangesAsync(); + + return profiles; + } +} diff --git a/tests/Place.Api.Integration.Tests/Place.Api.Integration.Tests.csproj b/tests/Place.Api.Integration.Tests/Place.Api.Integration.Tests.csproj new file mode 100755 index 0000000..6eb8268 --- /dev/null +++ b/tests/Place.Api.Integration.Tests/Place.Api.Integration.Tests.csproj @@ -0,0 +1,34 @@ + + + + false + true + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + + + +