diff --git a/Directory.Packages.props b/Directory.Packages.props index b80cb2c..ce949ac 100755 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -42,6 +42,8 @@ <PackageVersion Include="NWebsec.AspNetCore.Core" Version="3.0.1" /> <PackageVersion Include="NWebsec.AspNetCore.Middleware" Version="3.0.0" /> <PackageVersion Include="Respawn" Version="6.2.1" /> + <PackageVersion Include="runtime.unix.System.Private.Uri" Version="4.3.2" /> + <PackageVersion Include="runtime.win7.System.Private.Uri" Version="4.3.2" /> <PackageVersion Include="Scalar.AspNetCore" Version="1.2.43" /> <PackageVersion Include="Serilog.Extensions.Hosting" Version="8.0.0" /> <PackageVersion Include="Serilog.Settings.Configuration" Version="8.0.4" /> @@ -62,6 +64,7 @@ <PackageVersion Include="OpenTelemetry.Instrumentation.Runtime" Version="1.9.0" /> <PackageVersion Include="coverlet.collector" Version="6.0.0" /> <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.8.0" /> + <PackageVersion Include="System.Private.Uri" Version="4.3.2" /> <PackageVersion Include="Testcontainers" Version="3.10.0" /> <PackageVersion Include="Testcontainers.PostgreSql" Version="3.10.0" /> <PackageVersion Include="xunit" Version="2.5.3" /> @@ -77,5 +80,6 @@ <PackageVersion Include="System.Text.RegularExpressions" Version="4.3.1" /> <PackageVersion Include="System.Net.Http" Version="4.3.4" /> <PackageVersion Update="Asp.Versioning.Mvc" Version="8.1.0" /> + <PackageVersion Update="System.Private.Uri" Version="4.3.2" /> </ItemGroup> </Project> \ No newline at end of file diff --git a/src/Account/Account.csproj b/src/Account/Account.csproj index 280b6a3..1e5e80d 100755 --- a/src/Account/Account.csproj +++ b/src/Account/Account.csproj @@ -1,5 +1,6 @@ -<Project Sdk="Microsoft.NET.Sdk.Web"> +<Project Sdk="Microsoft.NET.Sdk"> <ItemGroup> + <FrameworkReference Include="Microsoft.AspNetCore.App" /> <PackageReference Include="Ardalis.GuardClauses"/> <PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" /> <PackageReference Include="ErrorOr"/> @@ -15,12 +16,8 @@ <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - </ItemGroup> - - <ItemGroup> - <Content Include="..\..\.dockerignore"> - <Link>.dockerignore</Link> - </Content> + <PackageReference Update="System.Private.Uri" /> + <PackageReference Include="System.Private.Uri" /> </ItemGroup> <ItemGroup> @@ -33,6 +30,7 @@ </ItemGroup> <ItemGroup> + <Folder Include="Data\Migrations\" /> <Folder Include="Profile\Features\V1\" /> </ItemGroup> <ItemGroup> diff --git a/src/Account/AccountModule.cs b/src/Account/AccountModule.cs index ef7b7be..f40bc0d 100755 --- a/src/Account/AccountModule.cs +++ b/src/Account/AccountModule.cs @@ -18,11 +18,7 @@ public static IServiceCollection AddAccountModule( IConfiguration configuration ) { - services.AddPlaceDbContext<AccountDbContext>(nameof(Account), configuration); - services.AddPlaceDbContext<IdentityApplicationDbContext>( - nameof(Core.Identity), - configuration - ); + services.AddPlaceDbContext<AccountDbContext>("PlaceDb", configuration); services.AddScoped<IDataSeeder<AccountDbContext>, AccountDataSeeder>(); return services; } diff --git a/src/Account/Data/Configurations/AccountDbContext.cs b/src/Account/Data/Configurations/AccountDbContext.cs index f549811..8bcbd6a 100755 --- a/src/Account/Data/Configurations/AccountDbContext.cs +++ b/src/Account/Data/Configurations/AccountDbContext.cs @@ -1,26 +1,23 @@ -using Account.Data.Models; +using Account.Profile.Models; using Core.EF; using Microsoft.AspNetCore.Http; using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Storage; namespace Account.Data.Configurations; public class AccountDbContext : AppDbContextBase { - private IDbContextTransaction _currentTransaction; - public AccountDbContext( DbContextOptions<AccountDbContext> options, IHttpContextAccessor httpContextAccessor ) : base(options, httpContextAccessor) { } - public DbSet<ProfileReadModel> Profiles => Set<ProfileReadModel>(); + public DbSet<UserProfile> Profiles => Set<UserProfile>(); protected override void OnModelCreating(ModelBuilder modelBuilder) { - modelBuilder.ApplyConfiguration(new ProfileConfiguration()); + modelBuilder.ApplyConfiguration(new UserProfileConfiguration()); base.OnModelCreating(modelBuilder); } diff --git a/src/Account/Data/Configurations/ProfileConfiguration.cs b/src/Account/Data/Configurations/ProfileConfiguration.cs deleted file mode 100755 index 3cceff6..0000000 --- a/src/Account/Data/Configurations/ProfileConfiguration.cs +++ /dev/null @@ -1,77 +0,0 @@ -using Account.Data.Models; -using Account.Profile.Models; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Account.Data.Configurations; - -public class ProfileConfiguration : IEntityTypeConfiguration<ProfileReadModel> -{ - public void Configure(EntityTypeBuilder<ProfileReadModel> builder) - { - builder.ToTable("Profiles"); - - builder.HasKey(p => p.Id); - - builder.Property(p => p.Id).ValueGeneratedNever(); - - builder.Property(p => p.UserId).IsRequired(); - - builder.Property(p => p.Email).IsRequired().HasMaxLength(Email.MaxLength); - - // Date properties with appropriate data type - builder.Property(p => p.DateOfBirth).HasColumnType("date"); // Stores only the date part, no time - - builder.Property(p => p.CreatedAt).IsRequired().HasColumnType("timestamp with time zone"); // Full timestamp for audit - - builder.Property(p => p.LastModifiedAt).HasColumnType("timestamp with time zone"); - - builder.Property(p => p.DeletedAt).HasColumnType("timestamp with time zone"); - - builder.Property(p => p.CreatedBy).IsRequired(); - - // PersonalInfo properties - builder.Property(p => p.FirstName).HasMaxLength(FirstName.MaxLength); - - builder.Property(p => p.LastName).HasMaxLength(LastName.MaxLength); - - builder.Property(p => p.PhoneNumber); - - // Address properties - builder.Property(p => p.Street); - - builder.Property(p => p.ZipCode); - - builder.Property(p => p.City); - - builder.Property(p => p.Country); - - builder.Property(p => p.AdditionalAddressDetails); - - // Enum conversion - builder.Property(p => p.Gender).HasConversion<int>(); - - // Precision for coordinates - builder.Property(p => p.Latitude).HasPrecision(9, 6); // Allows for precise GPS coordinates - - builder.Property(p => p.Longitude).HasPrecision(9, 6); - - // Query Filter to exclude soft deleted profiles - builder.HasQueryFilter(p => !p.DeletedAt.HasValue); - - // Unique email index, only for non-deleted profiles - builder.HasIndex(p => p.Email).IsUnique().HasFilter("\"IsDeleted\" = false"); - - // Index for last name and first name, only for non-deleted profiles - builder.HasIndex(p => new { p.LastName, p.FirstName }).HasFilter("\"IsDeleted\" = false"); - - builder.HasIndex(p => p.DateOfBirth); - - builder.HasIndex(p => p.DateOfBirth); - - builder.HasIndex(p => p.IsDeleted); - - // Global query filter for soft delete - builder.HasQueryFilter(p => !p.IsDeleted); - } -} diff --git a/src/Account/Data/Configurations/UserProfileConfiguration.cs b/src/Account/Data/Configurations/UserProfileConfiguration.cs new file mode 100755 index 0000000..7749122 --- /dev/null +++ b/src/Account/Data/Configurations/UserProfileConfiguration.cs @@ -0,0 +1,125 @@ +using Account.Profile.Models; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +public class UserProfileConfiguration : IEntityTypeConfiguration<UserProfile> +{ + public void Configure(EntityTypeBuilder<UserProfile> builder) + { + builder.ToTable("Profiles"); + + builder.HasKey(p => p.Id); + builder + .Property(p => p.Id) + .HasConversion(id => id.Value, value => new UserProfileId(value)) + .ValueGeneratedNever(); + + builder.Property(p => p.UserId).IsRequired(); + + builder + .Property(p => p.Email) + .HasConversion(email => email.Value, value => Email.Create(value).Value) + .IsRequired(); + + builder.Property(p => p.CreatedAt).IsRequired().HasColumnType("timestamp with time zone"); + + builder.Property(p => p.CreatedBy).IsRequired(); + + builder.Property(p => p.LastModifiedAt).HasColumnType("timestamp with time zone"); + + builder.Property(p => p.LastModifiedBy); + + builder.Property(p => p.DeletedAt).HasColumnType("timestamp with time zone"); + + builder.Property(p => p.DeletedBy); + + builder.Property(p => p.IsDeleted).IsRequired().HasDefaultValue(false); + + // Configuration de PersonalInfo comme Owned Entity + builder.OwnsOne( + p => p.PersonalInfo, + personalInfo => + { + personalInfo + .Property(pi => pi.FirstName) + .HasConversion(ln => ln!.Value, value => FirstName.Create(value).Value) + .HasMaxLength(FirstName.MaxLength) + .HasColumnName("FirstName"); + + personalInfo + .Property(pi => pi.LastName) + .HasConversion(ln => ln!.Value, value => LastName.Create(value).Value) + .HasMaxLength(LastName.MaxLength) + .HasColumnName("LastName"); + + personalInfo + .Property(pi => pi.DateOfBirth) + .HasConversion(dob => dob!.Value, value => DateOfBirth.Create(value).Value) + .HasColumnType("timestamp with time zone") + .HasColumnName("DateOfBirth"); + + personalInfo + .Property(pi => pi.PhoneNumber) + .HasConversion(phone => phone!.Value, value => PhoneNumber.Parse(value).Value) + .HasColumnName("PhoneNumber"); + + personalInfo.Property(pi => pi.Gender).HasConversion<int>().HasColumnName("Gender"); + + personalInfo.OwnsOne( + pi => pi.Address, + address => + { + address.Property(a => a.Street).HasColumnName("Street"); + + address.Property(a => a.ZipCode).HasColumnName("ZipCode"); + + address.Property(a => a.City).HasColumnName("City"); + + address.Property(a => a.Country).HasColumnName("Country"); + + address + .Property(a => a.AdditionalDetails) + .HasColumnName("AdditionalAddressDetails"); + + address.OwnsOne( + a => a.Coordinates, + coordinates => + { + coordinates + .Property(c => c.Latitude) + .HasPrecision(9, 6) + .HasColumnName("Latitude"); + + coordinates + .Property(c => c.Longitude) + .HasPrecision(9, 6) + .HasColumnName("Longitude"); + } + ); + } + ); + } + ); + + builder + .Property(p => p.DeletedAt) + .HasColumnName("DeletedAt") + .HasColumnType("timestamp with time zone"); + + builder.Property(p => p.DeletedBy).HasColumnName("DeletedBy"); + + builder + .Property(p => p.IsDeleted) + .HasColumnName("IsDeleted") + .IsRequired() + .HasDefaultValue(false); + + builder.HasIndex(p => p.Email).IsUnique(); + + builder.HasIndex(p => p.IsDeleted); + + builder.HasIndex(p => p.IsDeleted); + + builder.HasQueryFilter(p => !p.IsDeleted); + } +} diff --git a/src/Account/Data/DesignTimeDbContextFactory.cs b/src/Account/Data/DesignTimeDbContextFactory.cs deleted file mode 100644 index 8a4edb8..0000000 --- a/src/Account/Data/DesignTimeDbContextFactory.cs +++ /dev/null @@ -1,18 +0,0 @@ -using Account.Data.Configurations; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Design; - -namespace Account.Data; - -public class DesignTimeDbContextFactory : IDesignTimeDbContextFactory<AccountDbContext> -{ - public AccountDbContext CreateDbContext(string[] args) - { - DbContextOptionsBuilder<AccountDbContext> builder = new(); - - builder.UseNpgsql( - "Server=localhost;Port=5432;Database=flight_db;User Id=postgres;Password=postgres;Include Error Detail=true" - ); - return new AccountDbContext(builder.Options, null); - } -} diff --git a/src/Account/Data/Migrations/20241123143503_Initial.Designer.cs b/src/Account/Data/Migrations/20241123143503_Initial.Designer.cs deleted file mode 100644 index 8a5ee4b..0000000 --- a/src/Account/Data/Migrations/20241123143503_Initial.Designer.cs +++ /dev/null @@ -1,120 +0,0 @@ -// <auto-generated /> -using System; -using Account.Data.Configurations; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Infrastructure; -using Microsoft.EntityFrameworkCore.Migrations; -using Microsoft.EntityFrameworkCore.Storage.ValueConversion; -using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; - -#nullable disable - -namespace Account.Data.Migrations -{ - [DbContext(typeof(AccountDbContext))] - [Migration("20241123143503_Initial")] - partial class Initial - { - /// <inheritdoc /> - protected override void BuildTargetModel(ModelBuilder modelBuilder) - { -#pragma warning disable 612, 618 - modelBuilder - .HasAnnotation("ProductVersion", "9.0.0") - .HasAnnotation("Relational:MaxIdentifierLength", 63); - - NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - - modelBuilder.Entity("Account.Data.Models.ProfileReadModel", b => - { - b.Property<Guid>("Id") - .HasColumnType("uuid"); - - b.Property<string>("AdditionalAddressDetails") - .HasColumnType("text"); - - b.Property<string>("City") - .HasColumnType("text"); - - b.Property<string>("Country") - .HasColumnType("text"); - - b.Property<DateTime>("CreatedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<Guid>("CreatedBy") - .HasColumnType("uuid"); - - b.Property<DateTime?>("DateOfBirth") - .HasColumnType("date"); - - b.Property<DateTime?>("DeletedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<Guid?>("DeletedBy") - .HasColumnType("uuid"); - - b.Property<string>("Email") - .IsRequired() - .HasMaxLength(254) - .HasColumnType("character varying(254)"); - - b.Property<string>("FirstName") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property<int?>("Gender") - .HasColumnType("integer"); - - b.Property<bool>("IsDeleted") - .HasColumnType("boolean"); - - b.Property<DateTime?>("LastModifiedAt") - .HasColumnType("timestamp with time zone"); - - b.Property<Guid?>("LastModifiedBy") - .HasColumnType("uuid"); - - b.Property<string>("LastName") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property<double?>("Latitude") - .HasPrecision(9, 6) - .HasColumnType("double precision"); - - b.Property<double?>("Longitude") - .HasPrecision(9, 6) - .HasColumnType("double precision"); - - b.Property<string>("PhoneNumber") - .HasColumnType("text"); - - b.Property<string>("Street") - .HasColumnType("text"); - - b.Property<Guid>("UserId") - .HasColumnType("uuid"); - - b.Property<string>("ZipCode") - .HasColumnType("text"); - - b.HasKey("Id"); - - b.HasIndex("DateOfBirth"); - - b.HasIndex("Email") - .IsUnique() - .HasFilter("\"IsDeleted\" = false"); - - b.HasIndex("IsDeleted"); - - b.HasIndex("LastName", "FirstName") - .HasFilter("\"IsDeleted\" = false"); - - b.ToTable("Profiles", (string)null); - }); -#pragma warning restore 612, 618 - } - } -} diff --git a/src/Account/Data/Migrations/20241201114524_Initial.Designer.cs b/src/Account/Data/Migrations/20241201114524_Initial.Designer.cs new file mode 100644 index 0000000..0e5dbbf --- /dev/null +++ b/src/Account/Data/Migrations/20241201114524_Initial.Designer.cs @@ -0,0 +1,187 @@ +// <auto-generated /> +using System; +using Account.Data.Configurations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Account.Data.Migrations +{ + [DbContext(typeof(AccountDbContext))] + [Migration("20241201114524_Initial")] + partial class Initial + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Account.Profile.Models.UserProfile", b => + { + b.Property<Guid>("Id") + .HasColumnType("uuid"); + + b.Property<DateTime>("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid>("CreatedBy") + .HasColumnType("uuid"); + + b.Property<DateTime?>("DeletedAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("DeletedAt"); + + b.Property<Guid?>("DeletedBy") + .HasColumnType("uuid") + .HasColumnName("DeletedBy"); + + b.Property<string>("Email") + .IsRequired() + .HasColumnType("text"); + + b.Property<bool>("IsDeleted") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property<DateTime?>("LastModified") + .HasColumnType("timestamp without time zone"); + + b.Property<DateTime?>("LastModifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property<Guid?>("LastModifiedBy") + .HasColumnType("uuid"); + + b.Property<Guid>("UserId") + .HasColumnType("uuid"); + + b.Property<long>("Version") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("IsDeleted"); + + b.ToTable("Profiles", (string)null); + }); + + modelBuilder.Entity("Account.Profile.Models.UserProfile", b => + { + b.OwnsOne("Account.Profile.Models.PersonalInfo", "PersonalInfo", b1 => + { + b1.Property<Guid>("UserProfileId") + .HasColumnType("uuid"); + + b1.Property<DateTime?>("DateOfBirth") + .HasColumnType("timestamp with time zone") + .HasColumnName("DateOfBirth"); + + b1.Property<string>("FirstName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("FirstName"); + + b1.Property<int?>("Gender") + .HasColumnType("integer") + .HasColumnName("Gender"); + + b1.Property<string>("LastName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("LastName"); + + b1.Property<string>("PhoneNumber") + .HasColumnType("text") + .HasColumnName("PhoneNumber"); + + b1.HasKey("UserProfileId"); + + b1.ToTable("Profiles"); + + b1.WithOwner() + .HasForeignKey("UserProfileId"); + + b1.OwnsOne("Account.Profile.Models.Address", "Address", b2 => + { + b2.Property<Guid>("PersonalInfoUserProfileId") + .HasColumnType("uuid"); + + b2.Property<string>("AdditionalDetails") + .HasColumnType("text") + .HasColumnName("AdditionalAddressDetails"); + + b2.Property<string>("City") + .IsRequired() + .HasColumnType("text") + .HasColumnName("City"); + + b2.Property<string>("Country") + .IsRequired() + .HasColumnType("text") + .HasColumnName("Country"); + + b2.Property<string>("Street") + .HasColumnType("text") + .HasColumnName("Street"); + + b2.Property<string>("ZipCode") + .HasColumnType("text") + .HasColumnName("ZipCode"); + + b2.HasKey("PersonalInfoUserProfileId"); + + b2.ToTable("Profiles"); + + b2.WithOwner() + .HasForeignKey("PersonalInfoUserProfileId"); + + b2.OwnsOne("Account.Profile.Models.GeoCoordinates", "Coordinates", b3 => + { + b3.Property<Guid>("AddressPersonalInfoUserProfileId") + .HasColumnType("uuid"); + + b3.Property<double>("Latitude") + .HasPrecision(9, 6) + .HasColumnType("double precision") + .HasColumnName("Latitude"); + + b3.Property<double>("Longitude") + .HasPrecision(9, 6) + .HasColumnType("double precision") + .HasColumnName("Longitude"); + + b3.HasKey("AddressPersonalInfoUserProfileId"); + + b3.ToTable("Profiles"); + + b3.WithOwner() + .HasForeignKey("AddressPersonalInfoUserProfileId"); + }); + + b2.Navigation("Coordinates"); + }); + + b1.Navigation("Address"); + }); + + b.Navigation("PersonalInfo") + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Account/Data/Migrations/20241123143503_Initial.cs b/src/Account/Data/Migrations/20241201114524_Initial.cs similarity index 81% rename from src/Account/Data/Migrations/20241123143503_Initial.cs rename to src/Account/Data/Migrations/20241201114524_Initial.cs index deb64b4..cd7c85c 100644 --- a/src/Account/Data/Migrations/20241123143503_Initial.cs +++ b/src/Account/Data/Migrations/20241201114524_Initial.cs @@ -17,10 +17,10 @@ protected override void Up(MigrationBuilder migrationBuilder) { Id = table.Column<Guid>(type: "uuid", nullable: false), UserId = table.Column<Guid>(type: "uuid", nullable: false), - Email = table.Column<string>(type: "character varying(254)", maxLength: 254, nullable: false), + Email = table.Column<string>(type: "text", nullable: false), FirstName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true), LastName = table.Column<string>(type: "character varying(100)", maxLength: 100, nullable: true), - DateOfBirth = table.Column<DateTime>(type: "date", nullable: true), + DateOfBirth = table.Column<DateTime>(type: "timestamp with time zone", nullable: true), Gender = table.Column<int>(type: "integer", nullable: true), PhoneNumber = table.Column<string>(type: "text", nullable: true), Street = table.Column<string>(type: "text", nullable: true), @@ -34,37 +34,27 @@ protected override void Up(MigrationBuilder migrationBuilder) CreatedBy = table.Column<Guid>(type: "uuid", nullable: false), LastModifiedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true), LastModifiedBy = table.Column<Guid>(type: "uuid", nullable: true), - IsDeleted = table.Column<bool>(type: "boolean", nullable: false), + IsDeleted = table.Column<bool>(type: "boolean", nullable: false, defaultValue: false), DeletedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true), - DeletedBy = table.Column<Guid>(type: "uuid", nullable: true) + DeletedBy = table.Column<Guid>(type: "uuid", nullable: true), + LastModified = table.Column<DateTime>(type: "timestamp without time zone", nullable: true), + Version = table.Column<long>(type: "bigint", nullable: false) }, constraints: table => { table.PrimaryKey("PK_Profiles", x => x.Id); }); - migrationBuilder.CreateIndex( - name: "IX_Profiles_DateOfBirth", - table: "Profiles", - column: "DateOfBirth"); - migrationBuilder.CreateIndex( name: "IX_Profiles_Email", table: "Profiles", column: "Email", - unique: true, - filter: "\"IsDeleted\" = false"); + unique: true); migrationBuilder.CreateIndex( name: "IX_Profiles_IsDeleted", table: "Profiles", column: "IsDeleted"); - - migrationBuilder.CreateIndex( - name: "IX_Profiles_LastName_FirstName", - table: "Profiles", - columns: new[] { "LastName", "FirstName" }, - filter: "\"IsDeleted\" = false"); } /// <inheritdoc /> diff --git a/src/Account/Data/Migrations/AccountDbContextModelSnapshot.cs b/src/Account/Data/Migrations/AccountDbContextModelSnapshot.cs index 624171f..f168318 100644 --- a/src/Account/Data/Migrations/AccountDbContextModelSnapshot.cs +++ b/src/Account/Data/Migrations/AccountDbContextModelSnapshot.cs @@ -22,49 +22,37 @@ protected override void BuildModel(ModelBuilder modelBuilder) NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); - modelBuilder.Entity("Account.Data.Models.ProfileReadModel", b => + modelBuilder.Entity("Account.Profile.Models.UserProfile", b => { b.Property<Guid>("Id") .HasColumnType("uuid"); - b.Property<string>("AdditionalAddressDetails") - .HasColumnType("text"); - - b.Property<string>("City") - .HasColumnType("text"); - - b.Property<string>("Country") - .HasColumnType("text"); - b.Property<DateTime>("CreatedAt") .HasColumnType("timestamp with time zone"); b.Property<Guid>("CreatedBy") .HasColumnType("uuid"); - b.Property<DateTime?>("DateOfBirth") - .HasColumnType("date"); - b.Property<DateTime?>("DeletedAt") - .HasColumnType("timestamp with time zone"); + .HasColumnType("timestamp with time zone") + .HasColumnName("DeletedAt"); b.Property<Guid?>("DeletedBy") - .HasColumnType("uuid"); + .HasColumnType("uuid") + .HasColumnName("DeletedBy"); b.Property<string>("Email") .IsRequired() - .HasMaxLength(254) - .HasColumnType("character varying(254)"); - - b.Property<string>("FirstName") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); - - b.Property<int?>("Gender") - .HasColumnType("integer"); + .HasColumnType("text"); b.Property<bool>("IsDeleted") - .HasColumnType("boolean"); + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false) + .HasColumnName("IsDeleted"); + + b.Property<DateTime?>("LastModified") + .HasColumnType("timestamp without time zone"); b.Property<DateTime?>("LastModifiedAt") .HasColumnType("timestamp with time zone"); @@ -72,44 +60,123 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property<Guid?>("LastModifiedBy") .HasColumnType("uuid"); - b.Property<string>("LastName") - .HasMaxLength(100) - .HasColumnType("character varying(100)"); + b.Property<Guid>("UserId") + .HasColumnType("uuid"); - b.Property<double?>("Latitude") - .HasPrecision(9, 6) - .HasColumnType("double precision"); + b.Property<long>("Version") + .HasColumnType("bigint"); - b.Property<double?>("Longitude") - .HasPrecision(9, 6) - .HasColumnType("double precision"); + b.HasKey("Id"); - b.Property<string>("PhoneNumber") - .HasColumnType("text"); + b.HasIndex("Email") + .IsUnique(); - b.Property<string>("Street") - .HasColumnType("text"); + b.HasIndex("IsDeleted"); - b.Property<Guid>("UserId") - .HasColumnType("uuid"); + b.ToTable("Profiles", (string)null); + }); - b.Property<string>("ZipCode") - .HasColumnType("text"); + modelBuilder.Entity("Account.Profile.Models.UserProfile", b => + { + b.OwnsOne("Account.Profile.Models.PersonalInfo", "PersonalInfo", b1 => + { + b1.Property<Guid>("UserProfileId") + .HasColumnType("uuid"); - b.HasKey("Id"); + b1.Property<DateTime?>("DateOfBirth") + .HasColumnType("timestamp with time zone") + .HasColumnName("DateOfBirth"); - b.HasIndex("DateOfBirth"); + b1.Property<string>("FirstName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("FirstName"); - b.HasIndex("Email") - .IsUnique() - .HasFilter("\"IsDeleted\" = false"); + b1.Property<int?>("Gender") + .HasColumnType("integer") + .HasColumnName("Gender"); - b.HasIndex("IsDeleted"); + b1.Property<string>("LastName") + .HasMaxLength(100) + .HasColumnType("character varying(100)") + .HasColumnName("LastName"); - b.HasIndex("LastName", "FirstName") - .HasFilter("\"IsDeleted\" = false"); + b1.Property<string>("PhoneNumber") + .HasColumnType("text") + .HasColumnName("PhoneNumber"); - b.ToTable("Profiles", (string)null); + b1.HasKey("UserProfileId"); + + b1.ToTable("Profiles"); + + b1.WithOwner() + .HasForeignKey("UserProfileId"); + + b1.OwnsOne("Account.Profile.Models.Address", "Address", b2 => + { + b2.Property<Guid>("PersonalInfoUserProfileId") + .HasColumnType("uuid"); + + b2.Property<string>("AdditionalDetails") + .HasColumnType("text") + .HasColumnName("AdditionalAddressDetails"); + + b2.Property<string>("City") + .IsRequired() + .HasColumnType("text") + .HasColumnName("City"); + + b2.Property<string>("Country") + .IsRequired() + .HasColumnType("text") + .HasColumnName("Country"); + + b2.Property<string>("Street") + .HasColumnType("text") + .HasColumnName("Street"); + + b2.Property<string>("ZipCode") + .HasColumnType("text") + .HasColumnName("ZipCode"); + + b2.HasKey("PersonalInfoUserProfileId"); + + b2.ToTable("Profiles"); + + b2.WithOwner() + .HasForeignKey("PersonalInfoUserProfileId"); + + b2.OwnsOne("Account.Profile.Models.GeoCoordinates", "Coordinates", b3 => + { + b3.Property<Guid>("AddressPersonalInfoUserProfileId") + .HasColumnType("uuid"); + + b3.Property<double>("Latitude") + .HasPrecision(9, 6) + .HasColumnType("double precision") + .HasColumnName("Latitude"); + + b3.Property<double>("Longitude") + .HasPrecision(9, 6) + .HasColumnType("double precision") + .HasColumnName("Longitude"); + + b3.HasKey("AddressPersonalInfoUserProfileId"); + + b3.ToTable("Profiles"); + + b3.WithOwner() + .HasForeignKey("AddressPersonalInfoUserProfileId"); + }); + + b2.Navigation("Coordinates"); + }); + + b1.Navigation("Address"); + }); + + b.Navigation("PersonalInfo") + .IsRequired(); }); #pragma warning restore 612, 618 } diff --git a/src/Account/Data/Seed/AccountDataSeeder.cs b/src/Account/Data/Seed/AccountDataSeeder.cs index d2705da..2dd5623 100644 --- a/src/Account/Data/Seed/AccountDataSeeder.cs +++ b/src/Account/Data/Seed/AccountDataSeeder.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Threading.Tasks; using Account.Data.Configurations; using Account.Data.Models; @@ -23,8 +24,7 @@ private async Task SeedProfileAsync() return; } - // Create sample profiles - List<ProfileReadModel> profiles = + List<ProfileReadModel> profilesData = [ new() { @@ -60,8 +60,6 @@ private async Task SeedProfileAsync() CreatedBy = Guid.NewGuid(), IsDeleted = false, }, - // Add a soft-deleted profile for testing - new() { Id = Guid.NewGuid(), @@ -76,7 +74,7 @@ private async Task SeedProfileAsync() DeletedBy = Guid.NewGuid(), }, ]; - + List<UserProfile> profiles = profilesData.Select(p => p.ToDomain().Value).ToList(); await context.Profiles.AddRangeAsync(profiles); await context.SaveChangesAsync(); } diff --git a/src/Account/Dockerfile b/src/Account/Dockerfile deleted file mode 100755 index c251367..0000000 --- a/src/Account/Dockerfile +++ /dev/null @@ -1,23 +0,0 @@ -FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS base -USER $APP_UID -WORKDIR /app -EXPOSE 8080 -EXPOSE 8081 - -FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build -ARG BUILD_CONFIGURATION=Release -WORKDIR /src -COPY ["src/Place.Api.ProfileManagement/Place.Api.ProfileManagement.csproj", "src/Place.Api.ProfileManagement/"] -RUN dotnet restore "src/Place.Api.ProfileManagement/Place.Api.ProfileManagement.csproj" -COPY . . -WORKDIR "/src/src/Place.Api.ProfileManagement" -RUN dotnet build "Place.Api.ProfileManagement.csproj" -c $BUILD_CONFIGURATION -o /app/build - -FROM build AS publish -ARG BUILD_CONFIGURATION=Release -RUN dotnet publish "Place.Api.ProfileManagement.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false - -FROM base AS final -WORKDIR /app -COPY --from=publish /app/publish . -ENTRYPOINT ["dotnet", "Place.Api.ProfileManagement.dll"] diff --git a/src/Account/Profile/Features/V1/GetPersonalInformation/GetPersonalInformationQuery.cs b/src/Account/Profile/Features/V1/GetPersonalInformation/GetPersonalInformationQuery.cs index 90439ec..1a3a746 100755 --- a/src/Account/Profile/Features/V1/GetPersonalInformation/GetPersonalInformationQuery.cs +++ b/src/Account/Profile/Features/V1/GetPersonalInformation/GetPersonalInformationQuery.cs @@ -3,6 +3,7 @@ using System.Threading.Tasks; using Account.Data.Configurations; using Account.Data.Models; +using Account.Profile.Models; using MediatR; using Microsoft.EntityFrameworkCore; @@ -19,10 +20,10 @@ public class GetProfileByIdQueryHandler(AccountDbContext dbContext) CancellationToken cancellationToken ) { - ProfileReadModel? profile = await dbContext + UserProfile? profile = await dbContext .Profiles.AsNoTracking() .FirstOrDefaultAsync(p => p.Id == request.ProfileId, cancellationToken); - return profile is null ? null : new PersonalInformationViewModel(profile); + return profile is null ? null : new PersonalInformationViewModel(profile.ToReadModel()); } } diff --git a/src/Account/Profile/Models/Address.cs b/src/Account/Profile/Models/Address.cs index c225b89..4d3b430 100755 --- a/src/Account/Profile/Models/Address.cs +++ b/src/Account/Profile/Models/Address.cs @@ -15,37 +15,7 @@ public sealed record Address private const int MaxZipCodeLength = 10; private const int MaxCountryLength = 50; - /// <summary> - /// Gets the optional street address line. - /// </summary> - /// <value>The street address, or null if not specified.</value> - public string? Street { get; } - - /// <summary> - /// Gets the optional postal/zip code. - /// </summary> - /// <value>The postal code, or null if not specified.</value> - public string? ZipCode { get; } - - /// <summary> - /// Gets the city name. - /// </summary> - public string City { get; } - - /// <summary> - /// Gets the country name. - /// </summary> - public string Country { get; } - - /// <summary> - /// Optional additional address details. - /// </summary> - public string? AdditionalDetails { get; } - - /// <summary> - /// Gets the geographical coordinates of the address. - /// </summary> - public GeoCoordinates? Coordinates { get; } + private Address() { } /// <summary> /// Initializes a new instance of the Address record. @@ -190,6 +160,38 @@ public override string ToString() return baseAddress; } + + /// <summary> + /// Gets the optional street address line. + /// </summary> + /// <value>The street address, or null if not specified.</value> + public string? Street { get; } + + /// <summary> + /// Gets the optional postal/zip code. + /// </summary> + /// <value>The postal code, or null if not specified.</value> + public string? ZipCode { get; } + + /// <summary> + /// Gets the city name. + /// </summary> + public string City { get; } + + /// <summary> + /// Gets the country name. + /// </summary> + public string Country { get; } + + /// <summary> + /// Optional additional address details. + /// </summary> + public string? AdditionalDetails { get; } + + /// <summary> + /// Gets the geographical coordinates of the address. + /// </summary> + public GeoCoordinates? Coordinates { get; } } /// <summary> @@ -203,20 +205,6 @@ public sealed record GeoCoordinates private const double MaxLongitude = 180.0; private const int CoordinatePrecision = 6; - private static readonly CultureInfo InvariantCulture = CultureInfo.InvariantCulture; - - /// <summary> - /// Gets the latitude in degrees. - /// </summary> - /// <value>The latitude value between -90 and 90 degrees.</value> - public double Latitude { get; } - - /// <summary> - /// Gets the longitude in degrees. - /// </summary> - /// <value>The longitude value between -180 and 180 degrees.</value> - public double Longitude { get; } - /// <summary> /// Initializes a new instance of the GeoCoordinates record. /// </summary> @@ -262,4 +250,18 @@ public static ErrorOr<GeoCoordinates> Create(double latitude, double longitude) /// </summary> public override string ToString() => $"{Latitude.ToString("F6", InvariantCulture)}, {Longitude.ToString("F6", InvariantCulture)}"; + + private static readonly CultureInfo InvariantCulture = CultureInfo.InvariantCulture; + + /// <summary> + /// Gets the latitude in degrees. + /// </summary> + /// <value>The latitude value between -90 and 90 degrees.</value> + public double Latitude { get; } + + /// <summary> + /// Gets the longitude in degrees. + /// </summary> + /// <value>The longitude value between -180 and 180 degrees.</value> + public double Longitude { get; } } diff --git a/src/Account/Profile/Models/FirstName.cs b/src/Account/Profile/Models/FirstName.cs index 7c7b395..e3348db 100755 --- a/src/Account/Profile/Models/FirstName.cs +++ b/src/Account/Profile/Models/FirstName.cs @@ -12,6 +12,8 @@ public sealed partial record FirstName /// </summary> public string Value { get; } + private FirstName() { } + private FirstName(string value) => Value = value; /// <summary> diff --git a/src/Account/Profile/Models/LastName.cs b/src/Account/Profile/Models/LastName.cs index ae86278..9a94534 100755 --- a/src/Account/Profile/Models/LastName.cs +++ b/src/Account/Profile/Models/LastName.cs @@ -5,6 +5,8 @@ namespace Account.Profile.Models; public sealed partial record LastName { + private LastName() { } + internal const int MaxLength = 100; /// <summary> diff --git a/src/Account/Profile/Models/UserProfile.cs b/src/Account/Profile/Models/UserProfile.cs index 8a797d6..23d956b 100755 --- a/src/Account/Profile/Models/UserProfile.cs +++ b/src/Account/Profile/Models/UserProfile.cs @@ -9,39 +9,7 @@ namespace Account.Profile.Models; /// </summary> public sealed class UserProfile : Aggregate<UserProfileId> { - /// <summary> - /// Gets the identifier of the user who owns this profile. - /// </summary> - public Guid UserId { get; private set; } - - /// <summary> - /// Gets the email address associated with this profile. - /// </summary> - public Email Email { get; private set; } - - /// <summary> - /// Gets the personal information associated with this profile. - /// </summary> - public PersonalInfo PersonalInfo { get; private set; } - - /// <summary> - /// Gets the date and time when this profile was created. - /// </summary> - public new DateTime CreatedAt { get; private set; } - public new Guid CreatedBy { get; private set; } - public DateTime? LastModifiedAt { get; private set; } - public new Guid? LastModifiedBy { get; private set; } - - /// <summary> - /// Gets a value indicating whether this profile has been deleted. - /// </summary> - public new bool IsDeleted { get; private set; } - - /// <summary> - /// Gets the date and time when this profile was deleted. - /// </summary> - public DateTime? DeletedAt { get; private set; } - public Guid? DeletedBy { get; private set; } + private UserProfile() { } private UserProfile( UserProfileId id, @@ -216,4 +184,38 @@ public ErrorOr<Success> Restore(DateTime modifiedAt, Guid modifiedBy) return Result.Success; } + + /// <summary> + /// Gets the identifier of the user who owns this profile. + /// </summary> + public Guid UserId { get; private set; } + + /// <summary> + /// Gets the email address associated with this profile. + /// </summary> + public Email Email { get; private set; } + + /// <summary> + /// Gets the personal information associated with this profile. + /// </summary> + public PersonalInfo PersonalInfo { get; private set; } + + /// <summary> + /// Gets the date and time when this profile was created. + /// </summary> + public new DateTime CreatedAt { get; private set; } + public new Guid CreatedBy { get; private set; } + public DateTime? LastModifiedAt { get; private set; } + public new Guid? LastModifiedBy { get; private set; } + + /// <summary> + /// Gets a value indicating whether this profile has been deleted. + /// </summary> + public new bool IsDeleted { get; private set; } + + /// <summary> + /// Gets the date and time when this profile was deleted. + /// </summary> + public DateTime? DeletedAt { get; private set; } + public Guid? DeletedBy { get; private set; } } diff --git a/src/Account/Program.cs b/src/Account/Program.cs deleted file mode 100755 index 657143b..0000000 --- a/src/Account/Program.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Account; -using Core.Framework; -using Core.MediatR; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.Configuration; - -WebApplicationBuilder builder = WebApplication.CreateBuilder(args); - -builder.AddCoreFramework(); -IConfiguration configuration = builder.Configuration; -IWebHostEnvironment environment = builder.Environment; - -builder.Services.AddAccountModule(configuration); - -builder.Services.AddCoreMediatR(typeof(AccountModule).Assembly); - -WebApplication app = builder.Build(); - -await app.UseAccountModule(environment); - -await app.RunAsync(); diff --git a/src/Common/Core.EF/AppDbContextBase.cs b/src/Common/Core.EF/AppDbContextBase.cs index d46d2a8..0ab9a41 100644 --- a/src/Common/Core.EF/AppDbContextBase.cs +++ b/src/Common/Core.EF/AppDbContextBase.cs @@ -18,7 +18,7 @@ namespace Core.EF; public abstract class AppDbContextBase : DbContext, IDbContext { - private IDbContextTransaction _currentTransaction; + private IDbContextTransaction _currentTransaction = null!; private readonly IHttpContextAccessor _httpContextAccessor; protected AppDbContextBase(DbContextOptions options, IHttpContextAccessor httpContextAccessor) diff --git a/src/Common/Core.EF/ServiceCollectionExtensions.cs b/src/Common/Core.EF/ServiceCollectionExtensions.cs index b09db9b..0eb4620 100644 --- a/src/Common/Core.EF/ServiceCollectionExtensions.cs +++ b/src/Common/Core.EF/ServiceCollectionExtensions.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.EntityFrameworkCore.Metadata; using Microsoft.EntityFrameworkCore.Query; using Microsoft.Extensions.Configuration; @@ -58,6 +59,32 @@ IConfiguration configuration return services; } + public static IServiceCollection AddPlaceDbContext<TContext>( + this IServiceCollection services, + Action<DbContextOptionsBuilder> optionsAction + ) + where TContext : DbContext, IDbContext + { + AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true); + + services.AddDbContext<TContext>( + (sp, options) => + { + optionsAction(options); + options.ConfigureWarnings(warnings => + warnings.Ignore(RelationalEventId.PendingModelChangesWarning) + ); + options.EnableSensitiveDataLogging( + sp.GetService<IWebHostEnvironment>()?.IsDevelopment() ?? false + ); + } + ); + + services.AddScoped<IDbContext>(provider => provider.GetService<TContext>()!); + + return services; + } + public static async Task<IApplicationBuilder> UseMigrationAsync<TContext>( this IApplicationBuilder app, IWebHostEnvironment env diff --git a/src/Common/Core.Identity/Core.Identity.csproj b/src/Common/Core.Identity/Core.Identity.csproj index b21e356..5b1bbc2 100755 --- a/src/Common/Core.Identity/Core.Identity.csproj +++ b/src/Common/Core.Identity/Core.Identity.csproj @@ -17,6 +17,8 @@ <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" /> <PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" /> + <PackageReference Update="System.Private.Uri" /> + <PackageReference Include="System.Private.Uri" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\Core.EF\Core.EF.csproj" /> diff --git a/src/Common/Core.Identity/Data/Migrations/20241123143628_Initial.Designer.cs b/src/Common/Core.Identity/Data/Migrations/20241130122729_Initial.Designer.cs similarity index 99% rename from src/Common/Core.Identity/Data/Migrations/20241123143628_Initial.Designer.cs rename to src/Common/Core.Identity/Data/Migrations/20241130122729_Initial.Designer.cs index 31c44e5..9d6825f 100644 --- a/src/Common/Core.Identity/Data/Migrations/20241123143628_Initial.Designer.cs +++ b/src/Common/Core.Identity/Data/Migrations/20241130122729_Initial.Designer.cs @@ -12,7 +12,7 @@ namespace Core.Identity.Data.Migrations { [DbContext(typeof(IdentityApplicationDbContext))] - [Migration("20241123143628_Initial")] + [Migration("20241130122729_Initial")] partial class Initial { /// <inheritdoc /> diff --git a/src/Common/Core.Identity/Data/Migrations/20241123143628_Initial.cs b/src/Common/Core.Identity/Data/Migrations/20241130122729_Initial.cs similarity index 100% rename from src/Common/Core.Identity/Data/Migrations/20241123143628_Initial.cs rename to src/Common/Core.Identity/Data/Migrations/20241130122729_Initial.cs diff --git a/src/Common/Place.API/Data/Identity/20241130111701_Initial.Designer.cs b/src/Common/Place.API/Data/Identity/20241130111701_Initial.Designer.cs new file mode 100644 index 0000000..8608300 --- /dev/null +++ b/src/Common/Place.API/Data/Identity/20241130111701_Initial.Designer.cs @@ -0,0 +1,277 @@ +// <auto-generated /> +using System; +using Core.Identity; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Core.Identity.Migrations +{ + [DbContext(typeof(IdentityApplicationDbContext))] + [Migration("20241130111701_Initial")] + partial class Initial + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "9.0.0") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Core.Identity.ApplicationUser", b => + { + b.Property<string>("Id") + .HasColumnType("text"); + + b.Property<int>("AccessFailedCount") + .HasColumnType("integer"); + + b.Property<string>("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property<string>("Email") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property<bool>("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property<bool>("LockoutEnabled") + .HasColumnType("boolean"); + + b.Property<DateTimeOffset?>("LockoutEnd") + .HasColumnType("timestamp with time zone"); + + b.Property<string>("NormalizedEmail") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property<string>("NormalizedUserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property<string>("PasswordHash") + .HasColumnType("text"); + + b.Property<string>("PhoneNumber") + .HasColumnType("text"); + + b.Property<bool>("PhoneNumberConfirmed") + .HasColumnType("boolean"); + + b.Property<string>("SecurityStamp") + .HasColumnType("text"); + + b.Property<bool>("TwoFactorEnabled") + .HasColumnType("boolean"); + + b.Property<string>("UserName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedEmail") + .HasDatabaseName("EmailIndex"); + + b.HasIndex("NormalizedUserName") + .IsUnique() + .HasDatabaseName("UserNameIndex"); + + b.ToTable("AspNetUsers", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRole", b => + { + b.Property<string>("Id") + .HasColumnType("text"); + + b.Property<string>("ConcurrencyStamp") + .IsConcurrencyToken() + .HasColumnType("text"); + + b.Property<string>("Name") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property<string>("NormalizedName") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName") + .IsUnique() + .HasDatabaseName("RoleNameIndex"); + + b.ToTable("AspNetRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<string>("ClaimType") + .HasColumnType("text"); + + b.Property<string>("ClaimValue") + .HasColumnType("text"); + + b.Property<string>("RoleId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetRoleClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b => + { + b.Property<int>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("Id")); + + b.Property<string>("ClaimType") + .HasColumnType("text"); + + b.Property<string>("ClaimValue") + .HasColumnType("text"); + + b.Property<string>("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserClaims", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b => + { + b.Property<string>("LoginProvider") + .HasColumnType("text"); + + b.Property<string>("ProviderKey") + .HasColumnType("text"); + + b.Property<string>("ProviderDisplayName") + .HasColumnType("text"); + + b.Property<string>("UserId") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("LoginProvider", "ProviderKey"); + + b.HasIndex("UserId"); + + b.ToTable("AspNetUserLogins", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b => + { + b.Property<string>("UserId") + .HasColumnType("text"); + + b.Property<string>("RoleId") + .HasColumnType("text"); + + b.HasKey("UserId", "RoleId"); + + b.HasIndex("RoleId"); + + b.ToTable("AspNetUserRoles", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b => + { + b.Property<string>("UserId") + .HasColumnType("text"); + + b.Property<string>("LoginProvider") + .HasColumnType("text"); + + b.Property<string>("Name") + .HasColumnType("text"); + + b.Property<string>("Value") + .HasColumnType("text"); + + b.HasKey("UserId", "LoginProvider", "Name"); + + b.ToTable("AspNetUserTokens", (string)null); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityRoleClaim<string>", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserClaim<string>", b => + { + b.HasOne("Core.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserLogin<string>", b => + { + b.HasOne("Core.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserRole<string>", b => + { + b.HasOne("Microsoft.AspNetCore.Identity.IdentityRole", null) + .WithMany() + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Core.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Microsoft.AspNetCore.Identity.IdentityUserToken<string>", b => + { + b.HasOne("Core.Identity.ApplicationUser", null) + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Common/Place.API/Data/Identity/20241130111701_Initial.cs b/src/Common/Place.API/Data/Identity/20241130111701_Initial.cs new file mode 100644 index 0000000..e78477c --- /dev/null +++ b/src/Common/Place.API/Data/Identity/20241130111701_Initial.cs @@ -0,0 +1,223 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Core.Identity.Migrations +{ + /// <inheritdoc /> + public partial class Initial : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "AspNetRoles", + columns: table => new + { + Id = table.Column<string>(type: "text", nullable: false), + Name = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true), + ConcurrencyStamp = table.Column<string>(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetUsers", + columns: table => new + { + Id = table.Column<string>(type: "text", nullable: false), + UserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedUserName = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true), + Email = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true), + NormalizedEmail = table.Column<string>(type: "character varying(256)", maxLength: 256, nullable: true), + EmailConfirmed = table.Column<bool>(type: "boolean", nullable: false), + PasswordHash = table.Column<string>(type: "text", nullable: true), + SecurityStamp = table.Column<string>(type: "text", nullable: true), + ConcurrencyStamp = table.Column<string>(type: "text", nullable: true), + PhoneNumber = table.Column<string>(type: "text", nullable: true), + PhoneNumberConfirmed = table.Column<bool>(type: "boolean", nullable: false), + TwoFactorEnabled = table.Column<bool>(type: "boolean", nullable: false), + LockoutEnd = table.Column<DateTimeOffset>(type: "timestamp with time zone", nullable: true), + LockoutEnabled = table.Column<bool>(type: "boolean", nullable: false), + AccessFailedCount = table.Column<int>(type: "integer", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "AspNetRoleClaims", + columns: table => new + { + Id = table.Column<int>(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + RoleId = table.Column<string>(type: "text", nullable: false), + ClaimType = table.Column<string>(type: "text", nullable: true), + ClaimValue = table.Column<string>(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetRoleClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetRoleClaims_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserClaims", + columns: table => new + { + Id = table.Column<int>(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + UserId = table.Column<string>(type: "text", nullable: false), + ClaimType = table.Column<string>(type: "text", nullable: true), + ClaimValue = table.Column<string>(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserClaims", x => x.Id); + table.ForeignKey( + name: "FK_AspNetUserClaims_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserLogins", + columns: table => new + { + LoginProvider = table.Column<string>(type: "text", nullable: false), + ProviderKey = table.Column<string>(type: "text", nullable: false), + ProviderDisplayName = table.Column<string>(type: "text", nullable: true), + UserId = table.Column<string>(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserLogins", x => new { x.LoginProvider, x.ProviderKey }); + table.ForeignKey( + name: "FK_AspNetUserLogins_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserRoles", + columns: table => new + { + UserId = table.Column<string>(type: "text", nullable: false), + RoleId = table.Column<string>(type: "text", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserRoles", x => new { x.UserId, x.RoleId }); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetRoles_RoleId", + column: x => x.RoleId, + principalTable: "AspNetRoles", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_AspNetUserRoles_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "AspNetUserTokens", + columns: table => new + { + UserId = table.Column<string>(type: "text", nullable: false), + LoginProvider = table.Column<string>(type: "text", nullable: false), + Name = table.Column<string>(type: "text", nullable: false), + Value = table.Column<string>(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_AspNetUserTokens", x => new { x.UserId, x.LoginProvider, x.Name }); + table.ForeignKey( + name: "FK_AspNetUserTokens_AspNetUsers_UserId", + column: x => x.UserId, + principalTable: "AspNetUsers", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateIndex( + name: "IX_AspNetRoleClaims_RoleId", + table: "AspNetRoleClaims", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "RoleNameIndex", + table: "AspNetRoles", + column: "NormalizedName", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserClaims_UserId", + table: "AspNetUserClaims", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserLogins_UserId", + table: "AspNetUserLogins", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_AspNetUserRoles_RoleId", + table: "AspNetUserRoles", + column: "RoleId"); + + migrationBuilder.CreateIndex( + name: "EmailIndex", + table: "AspNetUsers", + column: "NormalizedEmail"); + + migrationBuilder.CreateIndex( + name: "UserNameIndex", + table: "AspNetUsers", + column: "NormalizedUserName", + unique: true); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "AspNetRoleClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserClaims"); + + migrationBuilder.DropTable( + name: "AspNetUserLogins"); + + migrationBuilder.DropTable( + name: "AspNetUserRoles"); + + migrationBuilder.DropTable( + name: "AspNetUserTokens"); + + migrationBuilder.DropTable( + name: "AspNetRoles"); + + migrationBuilder.DropTable( + name: "AspNetUsers"); + } + } +} diff --git a/src/Identity/Identity.csproj b/src/Identity/Identity.csproj index b30c62e..a87e7c2 100644 --- a/src/Identity/Identity.csproj +++ b/src/Identity/Identity.csproj @@ -1,6 +1,7 @@ -<Project Sdk="Microsoft.NET.Sdk.Web"> +<Project Sdk="Microsoft.NET.Sdk"> <ItemGroup> + <FrameworkReference Include="Microsoft.AspNetCore.App" /> <PackageReference Include="Asp.Versioning.Http" /> <PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" /> <PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" /> @@ -24,6 +25,9 @@ <PackageReference Include="NWebsec.AspNetCore.Middleware" /> <PackageReference Include="Swashbuckle.AspNetCore" /> <PackageReference Include="Swashbuckle.AspNetCore.ReDoc" /> + <PackageReference Update="System.Private.Uri" /> + <PackageReference Include="System.Private.Uri" /> + </ItemGroup> <ItemGroup> diff --git a/src/Identity/IdentityMdoule.cs b/src/Identity/IdentityMdoule.cs index fdc8667..9f6592d 100644 --- a/src/Identity/IdentityMdoule.cs +++ b/src/Identity/IdentityMdoule.cs @@ -15,10 +15,7 @@ public static IServiceCollection AddIdentityModule( WebApplicationBuilder builder ) { - services.AddPlaceDbContext<IdentityApplicationDbContext>( - nameof(Core.Identity), - builder.Configuration - ); + services.AddPlaceDbContext<IdentityApplicationDbContext>("PlaceDb", builder.Configuration); builder .AddIdentity() diff --git a/src/Identity/Program.cs b/src/Identity/Program.cs deleted file mode 100644 index 3748aad..0000000 --- a/src/Identity/Program.cs +++ /dev/null @@ -1,22 +0,0 @@ -using Core.Framework; -using Identity; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; - -WebApplicationBuilder builder = WebApplication.CreateBuilder(args); -builder.AddCoreFramework(); -IWebHostEnvironment environment = builder.Environment; - -builder.Services.AddIdentityModule(builder); - -WebApplication app = builder.Build(); - - -{ - await app.UseIdentityModule(environment); - await app.RunAsync(); -} - -app?.MapGet("/", () => "Hello World!"); - -public partial class Program { } diff --git a/src/Place.API/HeathCheckExtensions.cs b/src/Place.API/HeathCheckExtensions.cs index b418323..9b82822 100644 --- a/src/Place.API/HeathCheckExtensions.cs +++ b/src/Place.API/HeathCheckExtensions.cs @@ -18,14 +18,9 @@ IConfiguration configuration services .AddHealthChecks() .AddNpgSql( - name: "profile-db", - connectionString: configuration.GetConnectionString("Account")!, - tags: new[] { "db", "profile" } - ) - .AddNpgSql( - name: "identity-db", - connectionString: configuration.GetConnectionString("Identity")!, - tags: new[] { "db", "identity" } + name: "place-db", + connectionString: configuration.GetConnectionString("PlaceDb")!, + tags: new[] { "db", "place-db" } ); return services; diff --git a/src/Place.API/IAPIMarker.cs b/src/Place.API/IAPIMarker.cs new file mode 100644 index 0000000..3e75511 --- /dev/null +++ b/src/Place.API/IAPIMarker.cs @@ -0,0 +1,3 @@ +namespace Place.API; + +public interface IAPIMarker { } diff --git a/src/Place.API/Place.API.csproj b/src/Place.API/Place.API.csproj index 638f7ee..c8d1128 100644 --- a/src/Place.API/Place.API.csproj +++ b/src/Place.API/Place.API.csproj @@ -3,7 +3,14 @@ <ItemGroup> <PackageReference Include="Microsoft.AspNetCore.OpenApi"/> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> + </PackageReference> + <PackageReference Include="runtime.unix.System.Private.Uri" /> + <PackageReference Include="runtime.win7.System.Private.Uri" /> <PackageReference Include="Scalar.AspNetCore" /> + <PackageReference Include="System.Private.Uri" /> </ItemGroup> <ItemGroup> diff --git a/src/Place.API/Program.cs b/src/Place.API/Program.cs index e55122e..d91a41c 100644 --- a/src/Place.API/Program.cs +++ b/src/Place.API/Program.cs @@ -1,5 +1,6 @@ using Account; using Core.Framework; +using Core.Identity; using Core.MediatR; using Identity; using Microsoft.AspNetCore.Builder; diff --git a/src/Place.API/appsettings.json b/src/Place.API/appsettings.json index 78fe984..683182a 100644 --- a/src/Place.API/appsettings.json +++ b/src/Place.API/appsettings.json @@ -38,8 +38,7 @@ "name": "Place API v1.0" }, "ConnectionStrings": { - "Account": "Server=localhost;Port=5490;Database=profile_db;User Id=postgres;Password=postgres;Include Error Detail=true", - "Identity": "Server=localhost;Port=5490;Database=identity_db;User Id=postgres;Password=postgres;Include Error Detail=true" + "PlaceDb": "Server=localhost;Port=5499;Database=place_db;User Id=postgres;Password=postgres;Include Error Detail=true" }, "Serilog": { "applicationName": "identity-service", diff --git a/tests/Account.IntegrationTests/Account.IntegrationTests.csproj b/tests/Account.IntegrationTests/Account.IntegrationTests.csproj index 7b87c82..24810fd 100755 --- a/tests/Account.IntegrationTests/Account.IntegrationTests.csproj +++ b/tests/Account.IntegrationTests/Account.IntegrationTests.csproj @@ -15,7 +15,9 @@ <PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="NSubstitute" /> <PackageReference Include="Respawn" /> + <PackageReference Include="runtime.unix.System.Private.Uri" /> <PackageReference Include="System.Net.Http" /> + <PackageReference Include="System.Private.Uri" /> <PackageReference Include="System.Text.RegularExpressions" /> <PackageReference Include="Testcontainers" /> <PackageReference Include="Testcontainers.PostgreSql" /> @@ -32,5 +34,6 @@ <ItemGroup> <ProjectReference Include="..\..\src\Account\Account.csproj" /> + <ProjectReference Include="..\..\src\Place.API\Place.API.csproj" /> </ItemGroup> </Project> diff --git a/tests/Account.IntegrationTests/Common/IntegrationTest.cs b/tests/Account.IntegrationTests/Common/IntegrationTest.cs index 87c73f6..4cd0299 100755 --- a/tests/Account.IntegrationTests/Common/IntegrationTest.cs +++ b/tests/Account.IntegrationTests/Common/IntegrationTest.cs @@ -6,7 +6,7 @@ namespace Account.IntegrationTests.Common; [Collection(nameof(ProfileApiCollection))] -public abstract class IntegrationTest : IAsyncLifetime +public abstract class IntegrationTest { private readonly ProfileWebAppFactory _factory; private readonly IServiceScope _scope; @@ -25,17 +25,6 @@ protected IntegrationTest(ProfileWebAppFactory factory) Seeder = _scope.ServiceProvider.GetRequiredService<TestDataSeeder>(); } - public virtual async Task InitializeAsync() - { - await _factory.ResetDatabaseAsync(); - } - - public virtual Task DisposeAsync() - { - _scope.Dispose(); - return Task.CompletedTask; - } - protected async Task<TResult> ExecuteInScopeAsync<TResult>( Func<IServiceProvider, Task<TResult>> action ) diff --git a/tests/Account.IntegrationTests/Common/ProfileTestDataBuilder.cs b/tests/Account.IntegrationTests/Common/ProfileTestDataBuilder.cs index 5508fff..e69de29 100755 --- a/tests/Account.IntegrationTests/Common/ProfileTestDataBuilder.cs +++ b/tests/Account.IntegrationTests/Common/ProfileTestDataBuilder.cs @@ -1,46 +0,0 @@ -using System; -using Account.Data.Models; -using Account.Profile.Models; - -namespace Account.IntegrationTests.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/Account.IntegrationTests/Common/ProfileWebAppFactory.cs b/tests/Account.IntegrationTests/Common/ProfileWebAppFactory.cs index 7760195..3dd3a48 100755 --- a/tests/Account.IntegrationTests/Common/ProfileWebAppFactory.cs +++ b/tests/Account.IntegrationTests/Common/ProfileWebAppFactory.cs @@ -1,16 +1,16 @@ -using System.Collections.Generic; using System.Threading.Tasks; -using Account; using Account.Data.Configurations; using Core.EF; using Core.Identity; +using DotNet.Testcontainers.Builders; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Npgsql; +using Place.API; using Respawn; using Testcontainers.PostgreSql; @@ -19,20 +19,22 @@ namespace Account.IntegrationTests.Common; [CollectionDefinition(nameof(ProfileApiCollection))] public class ProfileApiCollection : ICollectionFixture<ProfileWebAppFactory> { } -public class ProfileWebAppFactory : WebApplicationFactory<IAccountRoot>, IAsyncLifetime +public class ProfileWebAppFactory : WebApplicationFactory<IAPIMarker>, IAsyncLifetime { - private readonly PostgreSqlContainer _dbContainer = default!; - private Respawner? _respawner = default!; + private readonly PostgreSqlContainer _dbContainer; + private Respawner _respawner; private readonly RespawnerOptions _respawnerOptions; public string ConnectionString => _dbContainer.GetConnectionString(); public ProfileWebAppFactory() { _dbContainer = new PostgreSqlBuilder() - .WithImage("postgres:15.1") - .WithDatabase("PlaceApiIdentity") + .WithImage("postgres:latest") + .WithDatabase("TestPlaceDb") .WithUsername("postgres") .WithPassword("postgres") + .WithPortBinding(5555, 5432) + .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(5432)) .Build(); _respawnerOptions = new RespawnerOptions @@ -44,34 +46,55 @@ public ProfileWebAppFactory() protected override void ConfigureWebHost(IWebHostBuilder builder) { - builder.ConfigureServices(services => + builder.ConfigureTestServices(services => { - services.RemoveAll(typeof(DbContextOptions<AccountDbContext>)); - services.RemoveAll(typeof(AccountDbContext)); - services.RemoveAll<DbContextOptions<AccountDbContext>>(); services.RemoveAll(typeof(IDbContext)); services.RemoveAll(typeof(AppDbContextBase)); + services.RemoveAll(typeof(DbContext)); + services.RemoveAll(typeof(AccountDbContext)); + services.RemoveAll(typeof(IdentityApplicationDbContext)); + services.RemoveAll(typeof(DbContextOptions<AccountDbContext>)); + services.RemoveAll(typeof(DbContextOptions<IdentityApplicationDbContext>)); + services.RemoveAll(typeof(DbContextOptions<AppDbContextBase>)); - IConfigurationRoot configuration = new ConfigurationBuilder() - .AddInMemoryCollection( - new Dictionary<string, string> + services.AddPlaceDbContext<AccountDbContext>(options => + { + options.UseNpgsql( + _dbContainer.GetConnectionString(), + dbOptions => { - { "ConnectionStrings:AccountTestDb", _dbContainer.GetConnectionString() }, - }! - ) - .Build(); + dbOptions.MigrationsAssembly( + typeof(AccountDbContext).Assembly.GetName().Name + ); + dbOptions.EnableRetryOnFailure(3); + } + ); + }); - services.AddPlaceDbContext<AccountDbContext>("AccountTestDb", configuration); services.AddScoped<TestDataSeeder>(); + + services.AddPlaceDbContext<IdentityApplicationDbContext>(options => + { + options.UseNpgsql( + _dbContainer.GetConnectionString(), + dbOptions => + { + dbOptions.MigrationsAssembly( + typeof(AccountDbContext).Assembly.GetName().Name + ); + dbOptions.EnableRetryOnFailure(3); + } + ); + }); }); } public async Task ResetDatabaseAsync() { - await using NpgsqlConnection connection = new(ConnectionString); + await using NpgsqlConnection connection = new NpgsqlConnection(ConnectionString); await connection.OpenAsync(); - if (_respawner is null) + if (_respawner == null) { _respawner = await Respawner.CreateAsync(connection, _respawnerOptions); } @@ -82,6 +105,16 @@ public async Task ResetDatabaseAsync() public async Task InitializeAsync() { await _dbContainer.StartAsync(); + + // Initialize database and apply migrations + using IServiceScope scope = Services.CreateScope(); + AccountDbContext accountContext = + scope.ServiceProvider.GetRequiredService<AccountDbContext>(); + IdentityApplicationDbContext identityContext = + scope.ServiceProvider.GetRequiredService<IdentityApplicationDbContext>(); + + await accountContext.Database.MigrateAsync(); + await identityContext.Database.MigrateAsync(); } public new async Task DisposeAsync() diff --git a/tests/Account.IntegrationTests/Common/TestDataFactory.cs b/tests/Account.IntegrationTests/Common/TestDataFactory.cs deleted file mode 100755 index 2e2c3c1..0000000 --- a/tests/Account.IntegrationTests/Common/TestDataFactory.cs +++ /dev/null @@ -1,31 +0,0 @@ -using Account.Data.Models; -using Account.Profile.Models; - -namespace Account.IntegrationTests.Common; - -public static class TestDataFactory -{ - public static TheoryData<ProfileTestCase> ValidProfileTestCases => - new() - { - new ProfileTestCase("Jean", "Dupont", "jean.dupont@example.com", "+33612345678"), - new ProfileTestCase("Marie", "Martin", "marie.martin@example.com", "+33687654321"), - }; - - public static TheoryData<string?, string?, string> 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/Account.IntegrationTests/Common/TestDataSeeder.cs b/tests/Account.IntegrationTests/Common/TestDataSeeder.cs index f437c76..053c248 100755 --- a/tests/Account.IntegrationTests/Common/TestDataSeeder.cs +++ b/tests/Account.IntegrationTests/Common/TestDataSeeder.cs @@ -3,61 +3,94 @@ using Account.Data.Configurations; using Account.Data.Models; using Account.Profile.Models; +using ErrorOr; using Microsoft.EntityFrameworkCore; namespace Account.IntegrationTests.Common; -public class TestDataSeeder(AccountDbContext dbContext) +public class TestDataSeeder { - public async Task<ProfileReadModel> SeedBasicProfile() + private readonly AccountDbContext _dbContext; + + public TestDataSeeder(AccountDbContext dbContext) { - 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, - }; + _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); + } + + public async Task<UserProfile> SeedBasicProfile() + { + ProfileReadModel profile = new ProfileReadModel + { + 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, + }; + + ErrorOr<UserProfile> domainResult = profile.ToDomain(); + if (domainResult.IsError) + throw new InvalidOperationException( + $"Failed to create profile: {domainResult.FirstError.Description}" + ); - return await SeedProfile(profile); + return await SeedProfile(domainResult.Value); } - public async Task<ProfileReadModel> SeedProfile(ProfileReadModel profile) + public async Task<UserProfile> SeedProfile(UserProfile profile) { - dbContext.Profiles.Add(profile); - await dbContext.SaveChangesAsync(); + if (profile is null) + throw new ArgumentNullException(nameof(profile)); + + _dbContext.Profiles.Add(profile); + await _dbContext.SaveChangesAsync(); - return await dbContext.Profiles.AsNoTracking().FirstAsync(p => p.Id == profile.Id); + UserProfile? savedProfile = await _dbContext + .Profiles.AsNoTracking() + .FirstOrDefaultAsync(p => p.Id == profile.Id); + + if (savedProfile is null) + throw new InvalidOperationException( + $"Failed to retrieve saved profile with ID: {profile.Id}" + ); + + return savedProfile; } - public async Task<ProfileReadModel> SeedPartialProfile() + public async Task<UserProfile> SeedPartialProfile() { - ProfileReadModel profile = - new() - { - Id = Guid.NewGuid(), - Email = "partial@example.com", - City = "Lyon", - Country = "France", - }; + ProfileReadModel profile = new ProfileReadModel + { + Id = Guid.NewGuid(), + Email = "partial@example.com", + City = "Lyon", + Country = "France", + }; + + ErrorOr<UserProfile> domainResult = profile.ToDomain(); + if (domainResult.IsError) + throw new InvalidOperationException( + $"Failed to create partial profile: {domainResult.FirstError.Description}" + ); - return await SeedProfile(profile); + return await SeedProfile(domainResult.Value); } - public async Task<ProfileReadModel[]> SeedMultipleProfiles(int count) + public async Task<UserProfile[]> SeedMultipleProfiles(int count) { - ProfileReadModel[] profiles = new ProfileReadModel[count]; + if (count <= 0) + throw new ArgumentException("Count must be positive", nameof(count)); + + UserProfile[] profiles = new UserProfile[count]; for (int i = 0; i < count; i++) { - profiles[i] = new ProfileReadModel + ProfileReadModel readModel = new ProfileReadModel { Id = Guid.NewGuid(), FirstName = $"User{i}", @@ -67,10 +100,18 @@ public async Task<ProfileReadModel[]> SeedMultipleProfiles(int count) City = "Paris", Country = "France", }; + + ErrorOr<UserProfile> domainResult = readModel.ToDomain(); + if (domainResult.IsError) + throw new InvalidOperationException( + $"Failed to create profile {i}: {domainResult.FirstError.Description}" + ); + + profiles[i] = domainResult.Value; } - dbContext.Profiles.AddRange(profiles); - await dbContext.SaveChangesAsync(); + await _dbContext.Profiles.AddRangeAsync(profiles); + await _dbContext.SaveChangesAsync(); return profiles; } diff --git a/tests/Account.IntegrationTests/Profile/Features/V1/Endpoints/GetPersonalInformation/GetPersonnalInfoTests.cs b/tests/Account.IntegrationTests/Profile/Features/V1/Endpoints/GetPersonalInformation/GetPersonnalInfoTests.cs index 2ff8d0d..ae56cf8 100755 --- a/tests/Account.IntegrationTests/Profile/Features/V1/Endpoints/GetPersonalInformation/GetPersonnalInfoTests.cs +++ b/tests/Account.IntegrationTests/Profile/Features/V1/Endpoints/GetPersonalInformation/GetPersonnalInfoTests.cs @@ -1,94 +1,18 @@ using System; using System.Net.Http; using System.Threading.Tasks; -using Account.Data.Models; using Account.IntegrationTests.Common; -using Account.IntegrationTests.Extensions; -using Account.Profile.Features.V1.GetPersonalInformation; -using Account.Profile.Models; namespace Account.IntegrationTests.Profile.Features.V1.Endpoints.GetPersonalInformation; [Collection(nameof(ProfileApiCollection))] -[Trait("Category", "PersonalInformation")] -public sealed class GetPersonalInformationTests(ProfileWebAppFactory factory) - : IntegrationTest(factory) +[Trait("Category", "Integration")] +public sealed class GetPersonalInformationTests : IntegrationTest { - [Theory] - [MemberData( - nameof(TestDataFactory.ValidProfileTestCases), - MemberType = typeof(TestDataFactory) - )] - public async Task GetPersonalInformation_WithValidProfile_ShouldReturnExpectedData( - ProfileTestCase testCase - ) + public GetPersonalInformationTests(ProfileWebAppFactory factory) + : base(factory) { - // 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, - GetPersonalInformationEndpoint.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, - GetPersonalInformationEndpoint.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, - GetPersonalInformationEndpoint.PersonalInformationResponse? Content - ) result = await this.Client.GetPersonalInformation(seededProfile.Id); - - // Assert - await result.ShouldReturnValidProfileAsync(seededProfile); + factory.ResetDatabaseAsync().GetAwaiter(); } [Fact] diff --git a/tests/Account.UnitTests/Account.UnitTests.csproj b/tests/Account.UnitTests/Account.UnitTests.csproj index f1c0220..cde675b 100755 --- a/tests/Account.UnitTests/Account.UnitTests.csproj +++ b/tests/Account.UnitTests/Account.UnitTests.csproj @@ -10,10 +10,12 @@ <PackageReference Include="FluentAssertions" /> <PackageReference Include="libphonenumber-csharp" /> <PackageReference Include="Microsoft.NET.Test.Sdk"/> + <PackageReference Include="System.Private.Uri" /> <PackageReference Include="xunit"/> <PackageReference Include="xunit.runner.visualstudio"/> <PackageReference Include="System.Net.Http" /> <PackageReference Include="System.Text.RegularExpressions"/> + <PackageReference Update="System.Private.Uri" /> </ItemGroup> diff --git a/tests/Identity.IntegrationTests/Common/UserManagerHelper.cs b/tests/Identity.IntegrationTests/Common/UserManagerHelper.cs index a1ce893..3728910 100755 --- a/tests/Identity.IntegrationTests/Common/UserManagerHelper.cs +++ b/tests/Identity.IntegrationTests/Common/UserManagerHelper.cs @@ -32,11 +32,13 @@ public UserManagementHelper(IServiceProvider serviceProvider) /// <param name="email">The email address for the new user.</param> /// <param name="password">The password for the new user.</param> /// <param name="emailConfirmed">Whether the email should be marked as confirmed. Default is true.</param> + /// <param name="userName">Identity username</param> /// <returns>The created ApplicationUser.</returns> public async Task<ApplicationUser> CreateUserAsync( string email, string password, - bool emailConfirmed = true + bool emailConfirmed = true, + string? userName = null ) { UserManager<ApplicationUser> userManager = _serviceProvider.GetRequiredService< @@ -53,7 +55,7 @@ public async Task<ApplicationUser> CreateUserAsync( ApplicationUser user = new() { - UserName = email, + UserName = userName ?? email, Email = email, EmailConfirmed = emailConfirmed, }; diff --git a/tests/Identity.IntegrationTests/Identity.IntegrationTests.csproj b/tests/Identity.IntegrationTests/Identity.IntegrationTests.csproj index 1f17723..b579d3b 100755 --- a/tests/Identity.IntegrationTests/Identity.IntegrationTests.csproj +++ b/tests/Identity.IntegrationTests/Identity.IntegrationTests.csproj @@ -11,6 +11,10 @@ <PrivateAssets>all</PrivateAssets> </PackageReference> <PackageReference Include="FluentAssertions" /> + <PackageReference Include="Respawn" /> + <PackageReference Include="runtime.unix.System.Private.Uri" /> + <PackageReference Include="runtime.win7.System.Private.Uri" /> + <PackageReference Include="System.Private.Uri" /> <PackageReference Include="System.Text.RegularExpressions"/> <PackageReference Include="System.Net.Http" /> <PackageReference Include="Microsoft.AspNetCore.Mvc.Testing" /> @@ -32,6 +36,7 @@ <ItemGroup> <ProjectReference Include="..\..\src\Identity\Identity.csproj" /> + <ProjectReference Include="..\..\src\Place.API\Place.API.csproj" /> </ItemGroup> </Project> diff --git a/tests/Identity.IntegrationTests/IdentityWebAppFactory.cs b/tests/Identity.IntegrationTests/IdentityWebAppFactory.cs index fe32e64..986cb78 100755 --- a/tests/Identity.IntegrationTests/IdentityWebAppFactory.cs +++ b/tests/Identity.IntegrationTests/IdentityWebAppFactory.cs @@ -1,57 +1,90 @@ using System; -using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; +using Account.Data.Configurations; using Core.EF; using Core.Identity; -using Identity; +using DotNet.Testcontainers.Builders; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Identity; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.AspNetCore.TestHost; using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; +using Npgsql; +using Place.API; +using Respawn; using Testcontainers.PostgreSql; -using Xunit; namespace Identity.IntegrationTests; -public class IdentityWebAppFactory : WebApplicationFactory<IIdentityRoot>, IAsyncLifetime +public class IdentityWebAppFactory : WebApplicationFactory<IAPIMarker>, IAsyncLifetime { private readonly PostgreSqlContainer _dbContainer; + private Respawner _respawner; + private readonly RespawnerOptions _respawnerOptions; + public string ConnectionString => _dbContainer.GetConnectionString(); public IdentityWebAppFactory() { _dbContainer = new PostgreSqlBuilder() - .WithDatabase("PlaceApiIdentity") + .WithImage("postgres:latest") + .WithDatabase("TestPlaceDb") .WithUsername("postgres") .WithPassword("postgres") + .WithPortBinding(5432, true) + .WithWaitStrategy(Wait.ForUnixContainer().UntilPortIsAvailable(5432)) + .WithCleanUp(true) .Build(); + + _respawnerOptions = new RespawnerOptions + { + DbAdapter = DbAdapter.Postgres, + SchemasToInclude = new[] { "public" }, + }; } protected override void ConfigureWebHost(IWebHostBuilder builder) { - builder.ConfigureServices(services => + builder.ConfigureTestServices(services => { - services.RemoveAll(typeof(IdentityApplicationDbContext)); - services.RemoveAll<DbContextOptions<IdentityApplicationDbContext>>(); services.RemoveAll(typeof(IDbContext)); services.RemoveAll(typeof(AppDbContextBase)); + services.RemoveAll(typeof(DbContext)); + services.RemoveAll(typeof(AccountDbContext)); + services.RemoveAll(typeof(IdentityApplicationDbContext)); + services.RemoveAll(typeof(DbContextOptions<AccountDbContext>)); + services.RemoveAll(typeof(DbContextOptions<IdentityApplicationDbContext>)); + services.RemoveAll(typeof(DbContextOptions<AppDbContextBase>)); - IConfigurationRoot configuration = new ConfigurationBuilder() - .AddInMemoryCollection( - new Dictionary<string, string> + services.AddPlaceDbContext<AccountDbContext>(options => + { + options.UseNpgsql( + _dbContainer.GetConnectionString(), + dbOptions => { - { "ConnectionStrings:IdentityTestDb", _dbContainer.GetConnectionString() }, - }! - ) - .Build(); - - services.AddPlaceDbContext<IdentityApplicationDbContext>( - "IdentityTestDb", - configuration - ); + dbOptions.MigrationsAssembly( + typeof(AccountDbContext).Assembly.GetName().Name + ); + dbOptions.EnableRetryOnFailure(3); + } + ); + }); + + services.AddPlaceDbContext<IdentityApplicationDbContext>(options => + { + options.UseNpgsql( + _dbContainer.GetConnectionString(), + dbOptions => + { + dbOptions.MigrationsAssembly( + typeof(IdentityApplicationDbContext).Assembly.GetName().Name + ); + dbOptions.EnableRetryOnFailure(3); + } + ); + }); ServiceDescriptor? identityBuilder = services.SingleOrDefault(d => d.ServiceType == typeof(IdentityBuilder) @@ -83,13 +116,36 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) }); } + public async Task ResetDatabaseAsync() + { + await using NpgsqlConnection connection = new NpgsqlConnection(ConnectionString); + await connection.OpenAsync(); + + if (_respawner == null) + { + _respawner = await Respawner.CreateAsync(connection, _respawnerOptions); + } + + await _respawner.ResetAsync(connection); + } + public async Task InitializeAsync() { await _dbContainer.StartAsync(); + + // Initialize database and apply migrations + using IServiceScope scope = Services.CreateScope(); + AccountDbContext accountContext = + scope.ServiceProvider.GetRequiredService<AccountDbContext>(); + IdentityApplicationDbContext identityContext = + scope.ServiceProvider.GetRequiredService<IdentityApplicationDbContext>(); + + await accountContext.Database.MigrateAsync(); + await identityContext.Database.MigrateAsync(); } public new async Task DisposeAsync() { - await _dbContainer.StopAsync(); + await _dbContainer.DisposeAsync(); } }