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
+
+
+
+
+
+
+
+
+
+
+