From ae98c561c56c00e731119664f466059c771eb69e Mon Sep 17 00:00:00 2001 From: Bertus Viljoen Date: Mon, 9 Dec 2024 06:00:47 +0200 Subject: [PATCH 1/2] feat(elevators): add elevator number property and update database schema - Introduced a new `Number` property for the `Elevator` class to uniquely identify elevators within a building. - Updated the database schema to include the `Number` column and added a unique index on the combination of `BuildingId` and `Number`. - Modified seed data to ensure new elevators are initialized with their respective numbers. - Removed unused `CustomResults.cs` file to streamline the codebase. --- .../Services/IInMemoryElevatorPoolService.cs | 26 ++ .../Services/InMemoryElevatorPoolService.cs | 122 +++++++ src/Domain/Elevators/Elevator.cs | 2 + src/Domain/Elevators/ElevatorItem.cs | 77 ++++ ...8120755_SeedDataElevatorNumber.Designer.cs | 341 ++++++++++++++++++ .../20241208120755_SeedDataElevatorNumber.cs | 103 ++++++ .../ApplicationDbContextModelSnapshot.cs | 17 +- .../ElevatorConfiguration.cs | 7 + .../SeedData/ApplicationDbContextSeedData.cs | 6 + src/Presentation/Extensions/CustomResults.cs | 74 ---- .../Extensions/ResultExtensions.cs | 14 + 11 files changed, 712 insertions(+), 77 deletions(-) create mode 100644 src/Application/Abstractions/Services/IInMemoryElevatorPoolService.cs create mode 100644 src/Application/Services/InMemoryElevatorPoolService.cs create mode 100644 src/Domain/Elevators/ElevatorItem.cs create mode 100644 src/Infrastructure/Migrations/20241208120755_SeedDataElevatorNumber.Designer.cs create mode 100644 src/Infrastructure/Migrations/20241208120755_SeedDataElevatorNumber.cs delete mode 100644 src/Presentation/Extensions/CustomResults.cs diff --git a/src/Application/Abstractions/Services/IInMemoryElevatorPoolService.cs b/src/Application/Abstractions/Services/IInMemoryElevatorPoolService.cs new file mode 100644 index 0000000..db5ea3b --- /dev/null +++ b/src/Application/Abstractions/Services/IInMemoryElevatorPoolService.cs @@ -0,0 +1,26 @@ +using Domain.Common; +using Domain.Elevators; + +namespace Application.Abstractions.Services; + +/// Interface for managing the in-memory pool of elevators. +public interface IInMemoryElevatorPoolService +{ + /// Gets an elevator by its ID. + /// The ID of the elevator. + /// The cancellation token for cancelling the operation. + /// A Result containing the elevator if found. + Task> GetElevatorByIdAsync(Guid elevatorId, CancellationToken cancellationToken); + + /// Updates an elevator's information in the pool. + /// The elevator with updated information. + /// The cancellation token for cancelling the operation. + /// A Result indicating success or failure. + Task UpdateElevatorAsync(ElevatorItem elevator, CancellationToken cancellationToken); + + /// Gets all elevators in a building. + /// The ID of the building. + /// The cancellation token for cancelling the operation. + /// A Result containing a list of all elevators in the building. + Task>> GetAllElevatorsAsync(Guid buildingId, CancellationToken cancellationToken); +} diff --git a/src/Application/Services/InMemoryElevatorPoolService.cs b/src/Application/Services/InMemoryElevatorPoolService.cs new file mode 100644 index 0000000..3bd1756 --- /dev/null +++ b/src/Application/Services/InMemoryElevatorPoolService.cs @@ -0,0 +1,122 @@ +using System.Collections.Concurrent; +using Application.Abstractions.Data; +using Application.Abstractions.Services; +using Domain.Common; +using Domain.Elevators; +using Microsoft.Extensions.Logging; +using Serilog; + +namespace Application.Services; + +/// +public class InMemoryElevatorPoolService( + ILogger logger, + IApplicationDbContext context) + : IInMemoryElevatorPoolService, IDisposable +{ + private readonly ConcurrentDictionary _elevators = new(); + private readonly SemaphoreSlim _semaphore = new(1, 1); + + /// + public async Task> GetElevatorByIdAsync(Guid elevatorId, CancellationToken cancellationToken) + { + try + { + await Task.Yield(); // Ensure async context + + // TryGetValue is already thread-safe in ConcurrentDictionary + if (_elevators.TryGetValue(elevatorId, out var elevator)) + { + // Create a deep copy to ensure thread safety + return Result.Success(elevator.Clone()); + } + + return Result.Failure( + new Error("GetElevatorById.NotFound", $"Elevator with ID {elevatorId} not found", ErrorType.NotFound)); + } + catch (Exception ex) + { + return Result.Failure( + new Error("GetElevatorById.Error", ex.Message, ErrorType.Failure)); + } + } + + /// + public async Task UpdateElevatorAsync(ElevatorItem elevator, CancellationToken cancellationToken) + { + try + { + await _semaphore.WaitAsync(cancellationToken); + try + { + await Task.Yield(); // Ensure async context + + // Create a deep copy of the elevator to ensure thread safety + var elevatorCopy = elevator.Clone(); + + if (_elevators.TryGetValue(elevatorCopy.Id, out var existingElevator)) + { + if (_elevators.TryUpdate(elevatorCopy.Id, elevatorCopy, existingElevator)) + { + return Result.Success(); + } + + return Result.Failure( + new Error("UpdateElevator.ConcurrencyError", "Failed to update elevator due to concurrent modification", ErrorType.Conflict)); + } + + if (_elevators.TryAdd(elevatorCopy.Id, elevatorCopy)) + { + return Result.Success(); + } + + return Result.Failure( + new Error("UpdateElevator.AddError", "Failed to add elevator to the pool", ErrorType.Failure)); + } + finally + { + _semaphore.Release(); + } + } + catch (Exception ex) + { + return Result.Failure( + new Error("UpdateElevator.Error", ex.Message, ErrorType.Failure)); + } + } + + /// + public async Task>> GetAllElevatorsAsync(Guid buildingId, CancellationToken cancellationToken) + { + try + { + await _semaphore.WaitAsync(cancellationToken); + try + { + await Task.Yield(); // Ensure async context + + // Create a deep copy of each elevator to ensure thread safety + var allElevators = _elevators.Values + .Where(e => e.BuildingId == buildingId) + .Select(e => e.Clone()) + .ToList(); + + return Result.Success>(allElevators); + } + finally + { + _semaphore.Release(); + } + } + catch (Exception ex) + { + return Result.Failure>( + new Error("GetAllElevators.Error", ex.Message, ErrorType.Failure)); + } + } + + public void Dispose() + { + _semaphore.Dispose(); + } +} diff --git a/src/Domain/Elevators/Elevator.cs b/src/Domain/Elevators/Elevator.cs index 9bf9f4f..7f758f5 100644 --- a/src/Domain/Elevators/Elevator.cs +++ b/src/Domain/Elevators/Elevator.cs @@ -9,6 +9,8 @@ public class Elevator : AuditableEntity { /// Get or set the unique identifier for the elevator. public required Guid Id { get; set; } + /// Get or set the number of the elevator. + public required int Number { get; set; } /// Get or set the current floor the elevator is on. public required int CurrentFloor { get; set; } /// Get or set the direction the elevator is moving. diff --git a/src/Domain/Elevators/ElevatorItem.cs b/src/Domain/Elevators/ElevatorItem.cs new file mode 100644 index 0000000..c83efcb --- /dev/null +++ b/src/Domain/Elevators/ElevatorItem.cs @@ -0,0 +1,77 @@ +using System.Text.Json.Serialization; + +namespace Domain.Elevators; + +/// Data transfer object representing an elevator in memory. +public class ElevatorItem +{ + /// Get or set the unique identifier for the elevator. + public Guid Id { get; set; } + + /// Get or set the number of the elevator. + public int Number { get; set; } + + /// Get or set the current floor the elevator is on. + public int CurrentFloor { get; set; } + + /// Get or set the direction the elevator is moving. + [JsonConverter(typeof(JsonStringEnumConverter))] + public ElevatorDirection ElevatorDirection { get; set; } + + /// Get or set the status of the elevator. + [JsonConverter(typeof(JsonStringEnumConverter))] + public ElevatorStatus ElevatorStatus { get; set; } + + /// Get or set the type of elevator. + [JsonConverter(typeof(JsonStringEnumConverter))] + public ElevatorType ElevatorType { get; set; } + + /// Get or set the speed of the elevator. + public double Speed { get; set; } = 0.5; + + /// Get or set the capacity of the elevator. + public int Capacity { get; set; } = 10; + + /// Get or set the unique identifier of the building the elevator is in. + public Guid BuildingId { get; set; } + + public static ElevatorItem FromElevator(Elevator elevator) => new() + { + Id = elevator.Id, + Number = elevator.Number, + CurrentFloor = elevator.CurrentFloor, + ElevatorDirection = elevator.ElevatorDirection, + ElevatorStatus = elevator.ElevatorStatus, + ElevatorType = elevator.ElevatorType, + Speed = elevator.Speed, + Capacity = elevator.Capacity, + BuildingId = elevator.BuildingId + }; + + public Elevator ToElevator() => new() + { + Id = Id, + Number = Number, + CurrentFloor = CurrentFloor, + ElevatorDirection = ElevatorDirection, + ElevatorStatus = ElevatorStatus, + ElevatorType = ElevatorType, + Speed = Speed, + Capacity = Capacity, + BuildingId = BuildingId + }; + + /// Creates a deep copy of the elevator item. + public ElevatorItem Clone() => new() + { + Id = Id, + Number = Number, + CurrentFloor = CurrentFloor, + ElevatorDirection = ElevatorDirection, + ElevatorStatus = ElevatorStatus, + ElevatorType = ElevatorType, + Speed = Speed, + Capacity = Capacity, + BuildingId = BuildingId + }; +} diff --git a/src/Infrastructure/Migrations/20241208120755_SeedDataElevatorNumber.Designer.cs b/src/Infrastructure/Migrations/20241208120755_SeedDataElevatorNumber.Designer.cs new file mode 100644 index 0000000..0a1dd7b --- /dev/null +++ b/src/Infrastructure/Migrations/20241208120755_SeedDataElevatorNumber.Designer.cs @@ -0,0 +1,341 @@ +// +using System; +using Infrastructure.Persistence.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Infrastructure.Migrations +{ + [DbContext(typeof(ApplicationDbContext))] + [Migration("20241208120755_SeedDataElevatorNumber")] + partial class SeedDataElevatorNumber + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("dbo") + .HasAnnotation("ProductVersion", "8.0.11"); + + modelBuilder.Entity("Domain.Buildings.Building", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT") + .HasColumnName("created_by_user_id"); + + b.Property("CreatedDateTimeUtc") + .HasColumnType("TEXT") + .HasColumnName("created_date_time_utc"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT") + .HasColumnName("name"); + + b.Property("NumberOfFloors") + .HasColumnType("INTEGER") + .HasColumnName("number_of_floors"); + + b.Property("UpdatedByUserId") + .HasColumnType("TEXT") + .HasColumnName("updated_by_user_id"); + + b.Property("UpdatedDateTimeUtc") + .HasColumnType("TEXT") + .HasColumnName("updated_date_time_utc"); + + b.HasKey("Id") + .HasName("pk_buildings"); + + b.HasIndex("CreatedByUserId") + .HasDatabaseName("ix_buildings_created_by_user_id"); + + b.HasIndex("Name") + .IsUnique() + .HasDatabaseName("ix_buildings_name"); + + b.HasIndex("UpdatedByUserId") + .HasDatabaseName("ix_buildings_updated_by_user_id"); + + b.ToTable("buildings", "dbo"); + + b.HasData( + new + { + Id = new Guid("e16e32e7-8db0-4536-b86e-f53e53cd7a0d"), + CreatedByUserId = new Guid("31a9cff7-dc59-4135-a762-6e814bab6f9a"), + CreatedDateTimeUtc = new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + Name = "Joe's Building", + NumberOfFloors = 10 + }); + }); + + modelBuilder.Entity("Domain.Elevators.Elevator", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("BuildingId") + .HasColumnType("TEXT") + .HasColumnName("building_id"); + + b.Property("Capacity") + .HasColumnType("INTEGER") + .HasColumnName("capacity"); + + b.Property("CreatedByUserId") + .HasColumnType("TEXT") + .HasColumnName("created_by_user_id"); + + b.Property("CreatedDateTimeUtc") + .HasColumnType("TEXT") + .HasColumnName("created_date_time_utc"); + + b.Property("CurrentFloor") + .HasColumnType("INTEGER") + .HasColumnName("current_floor"); + + b.Property("ElevatorDirection") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("elevator_direction"); + + b.Property("ElevatorStatus") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("elevator_status"); + + b.Property("ElevatorType") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("elevator_type"); + + b.Property("Number") + .HasColumnType("INTEGER") + .HasColumnName("number"); + + b.Property("Speed") + .HasColumnType("REAL") + .HasColumnName("speed"); + + b.Property("UpdatedByUserId") + .HasColumnType("TEXT") + .HasColumnName("updated_by_user_id"); + + b.Property("UpdatedDateTimeUtc") + .HasColumnType("TEXT") + .HasColumnName("updated_date_time_utc"); + + b.HasKey("Id") + .HasName("pk_elevators"); + + b.HasIndex("CreatedByUserId") + .HasDatabaseName("ix_elevators_created_by_user_id"); + + b.HasIndex("UpdatedByUserId") + .HasDatabaseName("ix_elevators_updated_by_user_id"); + + b.HasIndex("BuildingId", "Number") + .IsUnique() + .HasDatabaseName("ix_elevators_building_id_number"); + + b.ToTable("elevators", "dbo"); + + b.HasData( + new + { + Id = new Guid("852bb6fa-1831-49ef-a0d9-5bfa5f567841"), + BuildingId = new Guid("e16e32e7-8db0-4536-b86e-f53e53cd7a0d"), + Capacity = 10, + CreatedByUserId = new Guid("31a9cff7-dc59-4135-a762-6e814bab6f9a"), + CreatedDateTimeUtc = new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + CurrentFloor = 1, + ElevatorDirection = "None", + ElevatorStatus = "Active", + ElevatorType = "Passenger", + Number = 1, + Speed = 0.5 + }, + new + { + Id = new Guid("14ef29a8-001e-4b70-93b6-bfdb00237d46"), + BuildingId = new Guid("e16e32e7-8db0-4536-b86e-f53e53cd7a0d"), + Capacity = 10, + CreatedByUserId = new Guid("31a9cff7-dc59-4135-a762-6e814bab6f9a"), + CreatedDateTimeUtc = new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + CurrentFloor = 1, + ElevatorDirection = "None", + ElevatorStatus = "Active", + ElevatorType = "Passenger", + Number = 2, + Speed = 0.5 + }, + new + { + Id = new Guid("966b1041-ff39-432b-917c-b0a14ddce0bd"), + BuildingId = new Guid("e16e32e7-8db0-4536-b86e-f53e53cd7a0d"), + Capacity = 10, + CreatedByUserId = new Guid("31a9cff7-dc59-4135-a762-6e814bab6f9a"), + CreatedDateTimeUtc = new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + CurrentFloor = 1, + ElevatorDirection = "None", + ElevatorStatus = "Active", + ElevatorType = "Passenger", + Number = 3, + Speed = 0.5 + }, + new + { + Id = new Guid("b8557436-6472-4ad7-b111-09c8a023c463"), + BuildingId = new Guid("e16e32e7-8db0-4536-b86e-f53e53cd7a0d"), + Capacity = 10, + CreatedByUserId = new Guid("31a9cff7-dc59-4135-a762-6e814bab6f9a"), + CreatedDateTimeUtc = new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + CurrentFloor = 1, + ElevatorDirection = "None", + ElevatorStatus = "Active", + ElevatorType = "Passenger", + Number = 4, + Speed = 0.5 + }, + new + { + Id = new Guid("bbfbdffa-f7cd-4241-a222-85a733098782"), + BuildingId = new Guid("e16e32e7-8db0-4536-b86e-f53e53cd7a0d"), + Capacity = 10, + CreatedByUserId = new Guid("31a9cff7-dc59-4135-a762-6e814bab6f9a"), + CreatedDateTimeUtc = new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + CurrentFloor = 1, + ElevatorDirection = "None", + ElevatorStatus = "Active", + ElevatorType = "Service", + Number = 5, + Speed = 0.5 + }, + new + { + Id = new Guid("82d562f7-f7d5-4088-b735-9a7b085968d3"), + BuildingId = new Guid("e16e32e7-8db0-4536-b86e-f53e53cd7a0d"), + Capacity = 5, + CreatedByUserId = new Guid("31a9cff7-dc59-4135-a762-6e814bab6f9a"), + CreatedDateTimeUtc = new DateTime(1, 1, 1, 0, 0, 0, 0, DateTimeKind.Unspecified), + CurrentFloor = 1, + ElevatorDirection = "None", + ElevatorStatus = "Active", + ElevatorType = "HighSpeed", + Number = 6, + Speed = 1.0 + }); + }); + + modelBuilder.Entity("Domain.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT") + .HasColumnName("id"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("email"); + + b.Property("FirstName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("first_name"); + + b.Property("LastName") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("last_name"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT") + .HasColumnName("password_hash"); + + b.HasKey("Id") + .HasName("pk_users"); + + b.HasIndex("Email") + .IsUnique() + .HasDatabaseName("ix_users_email"); + + b.ToTable("users", "dbo"); + + b.HasData( + new + { + Id = new Guid("31a9cff7-dc59-4135-a762-6e814bab6f9a"), + Email = "admin@buiding.com", + FirstName = "Admin", + LastName = "Joe", + PasswordHash = "55BC042899399B562DD4A363FD250A9014C045B900716FCDC074861EB69C344A-B44367BE2D0B037E31AEEE2649199100" + }); + }); + + modelBuilder.Entity("Domain.Buildings.Building", b => + { + b.HasOne("Domain.Users.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_buildings_users_created_by_user_id"); + + b.HasOne("Domain.Users.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedByUserId") + .OnDelete(DeleteBehavior.Restrict) + .HasConstraintName("fk_buildings_users_updated_by_user_id"); + + b.Navigation("CreatedByUser"); + + b.Navigation("UpdatedByUser"); + }); + + modelBuilder.Entity("Domain.Elevators.Elevator", b => + { + b.HasOne("Domain.Buildings.Building", "Building") + .WithMany() + .HasForeignKey("BuildingId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired() + .HasConstraintName("fk_elevators_buildings_building_id"); + + b.HasOne("Domain.Users.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("fk_elevators_users_created_by_user_id"); + + b.HasOne("Domain.Users.User", "UpdatedByUser") + .WithMany() + .HasForeignKey("UpdatedByUserId") + .HasConstraintName("fk_elevators_users_updated_by_user_id"); + + b.Navigation("Building"); + + b.Navigation("CreatedByUser"); + + b.Navigation("UpdatedByUser"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Migrations/20241208120755_SeedDataElevatorNumber.cs b/src/Infrastructure/Migrations/20241208120755_SeedDataElevatorNumber.cs new file mode 100644 index 0000000..267c38d --- /dev/null +++ b/src/Infrastructure/Migrations/20241208120755_SeedDataElevatorNumber.cs @@ -0,0 +1,103 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Infrastructure.Migrations +{ + /// + public partial class SeedDataElevatorNumber : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_elevators_building_id", + schema: "dbo", + table: "elevators"); + + migrationBuilder.AddColumn( + name: "number", + schema: "dbo", + table: "elevators", + type: "INTEGER", + nullable: false, + defaultValue: 0); + + migrationBuilder.UpdateData( + schema: "dbo", + table: "elevators", + keyColumn: "id", + keyValue: new Guid("14ef29a8-001e-4b70-93b6-bfdb00237d46"), + column: "number", + value: 2); + + migrationBuilder.UpdateData( + schema: "dbo", + table: "elevators", + keyColumn: "id", + keyValue: new Guid("82d562f7-f7d5-4088-b735-9a7b085968d3"), + column: "number", + value: 6); + + migrationBuilder.UpdateData( + schema: "dbo", + table: "elevators", + keyColumn: "id", + keyValue: new Guid("852bb6fa-1831-49ef-a0d9-5bfa5f567841"), + column: "number", + value: 1); + + migrationBuilder.UpdateData( + schema: "dbo", + table: "elevators", + keyColumn: "id", + keyValue: new Guid("966b1041-ff39-432b-917c-b0a14ddce0bd"), + column: "number", + value: 3); + + migrationBuilder.UpdateData( + schema: "dbo", + table: "elevators", + keyColumn: "id", + keyValue: new Guid("b8557436-6472-4ad7-b111-09c8a023c463"), + column: "number", + value: 4); + + migrationBuilder.UpdateData( + schema: "dbo", + table: "elevators", + keyColumn: "id", + keyValue: new Guid("bbfbdffa-f7cd-4241-a222-85a733098782"), + column: "number", + value: 5); + + migrationBuilder.CreateIndex( + name: "ix_elevators_building_id_number", + schema: "dbo", + table: "elevators", + columns: new[] { "building_id", "number" }, + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "ix_elevators_building_id_number", + schema: "dbo", + table: "elevators"); + + migrationBuilder.DropColumn( + name: "number", + schema: "dbo", + table: "elevators"); + + migrationBuilder.CreateIndex( + name: "ix_elevators_building_id", + schema: "dbo", + table: "elevators", + column: "building_id"); + } + } +} diff --git a/src/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs b/src/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs index 5982b65..3de2ca6 100644 --- a/src/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs +++ b/src/Infrastructure/Migrations/ApplicationDbContextModelSnapshot.cs @@ -120,6 +120,10 @@ protected override void BuildModel(ModelBuilder modelBuilder) .HasColumnType("TEXT") .HasColumnName("elevator_type"); + b.Property("Number") + .HasColumnType("INTEGER") + .HasColumnName("number"); + b.Property("Speed") .HasColumnType("REAL") .HasColumnName("speed"); @@ -135,15 +139,16 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.HasKey("Id") .HasName("pk_elevators"); - b.HasIndex("BuildingId") - .HasDatabaseName("ix_elevators_building_id"); - b.HasIndex("CreatedByUserId") .HasDatabaseName("ix_elevators_created_by_user_id"); b.HasIndex("UpdatedByUserId") .HasDatabaseName("ix_elevators_updated_by_user_id"); + b.HasIndex("BuildingId", "Number") + .IsUnique() + .HasDatabaseName("ix_elevators_building_id_number"); + b.ToTable("elevators", "dbo"); b.HasData( @@ -158,6 +163,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) ElevatorDirection = "None", ElevatorStatus = "Active", ElevatorType = "Passenger", + Number = 1, Speed = 0.5 }, new @@ -171,6 +177,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) ElevatorDirection = "None", ElevatorStatus = "Active", ElevatorType = "Passenger", + Number = 2, Speed = 0.5 }, new @@ -184,6 +191,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) ElevatorDirection = "None", ElevatorStatus = "Active", ElevatorType = "Passenger", + Number = 3, Speed = 0.5 }, new @@ -197,6 +205,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) ElevatorDirection = "None", ElevatorStatus = "Active", ElevatorType = "Passenger", + Number = 4, Speed = 0.5 }, new @@ -210,6 +219,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) ElevatorDirection = "None", ElevatorStatus = "Active", ElevatorType = "Service", + Number = 5, Speed = 0.5 }, new @@ -223,6 +233,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) ElevatorDirection = "None", ElevatorStatus = "Active", ElevatorType = "HighSpeed", + Number = 6, Speed = 1.0 }); }); diff --git a/src/Infrastructure/Persistence/DatabaseConfiguration/ElevatorConfiguration.cs b/src/Infrastructure/Persistence/DatabaseConfiguration/ElevatorConfiguration.cs index 1812e57..4e8ee55 100644 --- a/src/Infrastructure/Persistence/DatabaseConfiguration/ElevatorConfiguration.cs +++ b/src/Infrastructure/Persistence/DatabaseConfiguration/ElevatorConfiguration.cs @@ -9,6 +9,13 @@ internal sealed class ElevatorConfiguration : IEntityTypeConfiguration public void Configure(EntityTypeBuilder builder) { builder.HasKey(e => e.Id); + + builder.Property(e => e.Number) + .IsRequired(); + + //HasIndex to be unique across building id and number + builder.HasIndex(e => new { e.BuildingId, e.Number }) + .IsUnique(); builder.Property(e => e.CurrentFloor) .IsRequired(); diff --git a/src/Infrastructure/Persistence/SeedData/ApplicationDbContextSeedData.cs b/src/Infrastructure/Persistence/SeedData/ApplicationDbContextSeedData.cs index cb52ed9..eda55c9 100644 --- a/src/Infrastructure/Persistence/SeedData/ApplicationDbContextSeedData.cs +++ b/src/Infrastructure/Persistence/SeedData/ApplicationDbContextSeedData.cs @@ -51,6 +51,7 @@ public static void SeedData(this ModelBuilder modelBuilder) { Id = Guid.Parse("852bb6fa-1831-49ef-a0d9-5bfa5f567841"), CurrentFloor = 1, + Number = 1, ElevatorDirection = ElevatorDirection.None, ElevatorStatus = ElevatorStatus.Active, ElevatorType = ElevatorType.Passenger, @@ -61,6 +62,7 @@ public static void SeedData(this ModelBuilder modelBuilder) { Id = Guid.Parse("14ef29a8-001e-4b70-93b6-bfdb00237d46"), CurrentFloor = 1, + Number = 2, ElevatorDirection = ElevatorDirection.None, ElevatorStatus = ElevatorStatus.Active, ElevatorType = ElevatorType.Passenger, @@ -71,6 +73,7 @@ public static void SeedData(this ModelBuilder modelBuilder) { Id = Guid.Parse("966b1041-ff39-432b-917c-b0a14ddce0bd"), CurrentFloor = 1, + Number = 3, ElevatorDirection = ElevatorDirection.None, ElevatorStatus = ElevatorStatus.Active, ElevatorType = ElevatorType.Passenger, @@ -81,6 +84,7 @@ public static void SeedData(this ModelBuilder modelBuilder) { Id = Guid.Parse("b8557436-6472-4ad7-b111-09c8a023c463"), CurrentFloor = 1, + Number = 4, ElevatorDirection = ElevatorDirection.None, ElevatorStatus = ElevatorStatus.Active, ElevatorType = ElevatorType.Passenger, @@ -91,6 +95,7 @@ public static void SeedData(this ModelBuilder modelBuilder) { Id = Guid.Parse("bbfbdffa-f7cd-4241-a222-85a733098782"), CurrentFloor = 1, + Number = 5, ElevatorDirection = ElevatorDirection.None, ElevatorStatus = ElevatorStatus.Active, ElevatorType = ElevatorType.Service, @@ -101,6 +106,7 @@ public static void SeedData(this ModelBuilder modelBuilder) { Id = Guid.Parse("82d562f7-f7d5-4088-b735-9a7b085968d3"), CurrentFloor = 1, + Number = 6, ElevatorDirection = ElevatorDirection.None, ElevatorStatus = ElevatorStatus.Active, ElevatorType = ElevatorType.HighSpeed, diff --git a/src/Presentation/Extensions/CustomResults.cs b/src/Presentation/Extensions/CustomResults.cs deleted file mode 100644 index 8a32415..0000000 --- a/src/Presentation/Extensions/CustomResults.cs +++ /dev/null @@ -1,74 +0,0 @@ -using Microsoft.AspNetCore.Http; -using Domain.Common; - -namespace Presentation.Extensions; - -public static class CustomResults -{ - public static IResult Problem(Result result) - { - if (result.IsSuccess) - { - throw new InvalidOperationException(); - } - - return Results.Problem( - title: GetTitle(result.Error), - detail: GetDetail(result.Error), - type: GetType(result.Error.Type), - statusCode: GetStatusCode(result.Error.Type), - extensions: GetErrors(result)); - - static string GetTitle(Error error) => - error.Type switch - { - ErrorType.Validation => error.Code, - ErrorType.Problem => error.Code, - ErrorType.NotFound => error.Code, - ErrorType.Conflict => error.Code, - _ => "Server failure" - }; - - static string GetDetail(Error error) => - error.Type switch - { - ErrorType.Validation => error.Description, - ErrorType.Problem => error.Description, - ErrorType.NotFound => error.Description, - ErrorType.Conflict => error.Description, - _ => "An unexpected error occurred" - }; - - static string GetType(ErrorType errorType) => - errorType switch - { - ErrorType.Validation => "https://tools.ietf.org/html/rfc7231#section-6.5.1", - ErrorType.Problem => "https://tools.ietf.org/html/rfc7231#section-6.5.1", - ErrorType.NotFound => "https://tools.ietf.org/html/rfc7231#section-6.5.4", - ErrorType.Conflict => "https://tools.ietf.org/html/rfc7231#section-6.5.8", - _ => "https://tools.ietf.org/html/rfc7231#section-6.6.1" - }; - - static int GetStatusCode(ErrorType errorType) => - errorType switch - { - ErrorType.Validation => StatusCodes.Status400BadRequest, - ErrorType.NotFound => StatusCodes.Status404NotFound, - ErrorType.Conflict => StatusCodes.Status409Conflict, - _ => StatusCodes.Status500InternalServerError - }; - - static Dictionary? GetErrors(Result result) - { - if (result.Error is not ValidationError validationError) - { - return null; - } - - return new Dictionary - { - { "errors", validationError.Errors } - }; - } - } -} diff --git a/src/Presentation/Extensions/ResultExtensions.cs b/src/Presentation/Extensions/ResultExtensions.cs index f554849..c4d2678 100644 --- a/src/Presentation/Extensions/ResultExtensions.cs +++ b/src/Presentation/Extensions/ResultExtensions.cs @@ -2,8 +2,15 @@ namespace Presentation.Extensions; +/// Extension methods for working with and . public static class ResultExtensions { + /// Method to match the result of a and execute the appropriate action. + /// The result to match. + /// The action to execute if the result is successful. + /// The action to execute if the result is a failure. + /// The type of the output. + /// The output of the executed action. public static TOut Match( this Result result, Func onSuccess, @@ -12,6 +19,13 @@ public static TOut Match( return result.IsSuccess ? onSuccess() : onFailure(result); } + /// Method to match the result of a and execute the appropriate action. + /// The result to match. + /// The action to execute if the result is successful. + /// The action to execute if the result is a failure. + /// The type of the input. + /// The type of the output. + /// The output of the executed action. public static TOut Match( this Result result, Func onSuccess, From e74e4cd511809ed2e5fc8d854b3efbaa2741d5e5 Mon Sep 17 00:00:00 2001 From: Bertus Viljoen Date: Mon, 9 Dec 2024 08:18:35 +0200 Subject: [PATCH 2/2] In Memory Elevator Pool Tests --- src/Application/DependencyInjection.cs | 5 + .../Services/InMemoryElevatorPoolService.cs | 103 +++++++++++++++-- src/Infrastructure/DependencyInjection.cs | 27 ++++- .../SeedData/ApplicationDbContextSeedData.cs | 77 ++++++------- .../ElevatorSimulationHostedService.cs | 76 +++++++++++++ src/Presentation/DependencyInjections.cs | 19 +--- .../ElevatorPools/ElevatorPoolsTests.cs | 106 ++++++++++++++++++ .../BuildingManagementTests.cs | 5 - 8 files changed, 344 insertions(+), 74 deletions(-) create mode 100644 src/Infrastructure/Services/ElevatorSimulationHostedService.cs create mode 100644 tests/ApplicationTests/ElevatorPools/ElevatorPoolsTests.cs diff --git a/src/Application/DependencyInjection.cs b/src/Application/DependencyInjection.cs index d1c9217..af1ab55 100644 --- a/src/Application/DependencyInjection.cs +++ b/src/Application/DependencyInjection.cs @@ -1,4 +1,6 @@ using Application.Abstractions.Behaviors; +using Application.Abstractions.Services; +using Application.Services; using FluentValidation; using Microsoft.Extensions.DependencyInjection; @@ -18,6 +20,9 @@ public static IServiceCollection AddApplication(this IServiceCollection services services.AddValidatorsFromAssembly(typeof(DependencyInjection).Assembly, includeInternalTypes: true); + // Register elevator services + services.AddSingleton(); + return services; } } diff --git a/src/Application/Services/InMemoryElevatorPoolService.cs b/src/Application/Services/InMemoryElevatorPoolService.cs index 3bd1756..1a0fe2f 100644 --- a/src/Application/Services/InMemoryElevatorPoolService.cs +++ b/src/Application/Services/InMemoryElevatorPoolService.cs @@ -3,23 +3,30 @@ using Application.Abstractions.Services; using Domain.Common; using Domain.Elevators; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; -using Serilog; namespace Application.Services; /// -public class InMemoryElevatorPoolService( +public sealed class InMemoryElevatorPoolService( ILogger logger, - IApplicationDbContext context) + IServiceProvider serviceProvider) : IInMemoryElevatorPoolService, IDisposable { + private readonly ILogger _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + private readonly IServiceProvider _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider)); private readonly ConcurrentDictionary _elevators = new(); private readonly SemaphoreSlim _semaphore = new(1, 1); + private DateTime _lastUpdate = DateTime.MinValue; // Initialize to MinValue to force first update + private readonly TimeSpan _updateInterval = TimeSpan.FromSeconds(30); + private bool _disposed; /// public async Task> GetElevatorByIdAsync(Guid elevatorId, CancellationToken cancellationToken) { + _logger.LogInformation("Getting elevator by ID {ElevatorId}", elevatorId); try { await Task.Yield(); // Ensure async context @@ -27,15 +34,32 @@ public async Task> GetElevatorByIdAsync(Guid elevatorId, Ca // TryGetValue is already thread-safe in ConcurrentDictionary if (_elevators.TryGetValue(elevatorId, out var elevator)) { + _logger.LogInformation("Elevator found by ID {ElevatorId}", elevatorId); // Create a deep copy to ensure thread safety return Result.Success(elevator.Clone()); } + // If not in cache, try to fetch from database + using var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var dbElevator = await context.Elevators + .FirstOrDefaultAsync(e => e.Id == elevatorId, cancellationToken); + + if (dbElevator != null) + { + var elevatorItem = ElevatorItem.FromElevator(dbElevator); + _elevators.TryAdd(elevatorId, elevatorItem); + return Result.Success(elevatorItem.Clone()); + } + + _logger.LogWarning("Elevator not found by ID {ElevatorId}", elevatorId); return Result.Failure( new Error("GetElevatorById.NotFound", $"Elevator with ID {elevatorId} not found", ErrorType.NotFound)); } catch (Exception ex) { + _logger.LogError(ex, "Error getting elevator by ID {ElevatorId}", elevatorId); return Result.Failure( new Error("GetElevatorById.Error", ex.Message, ErrorType.Failure)); } @@ -44,13 +68,12 @@ public async Task> GetElevatorByIdAsync(Guid elevatorId, Ca /// public async Task UpdateElevatorAsync(ElevatorItem elevator, CancellationToken cancellationToken) { + _logger.LogInformation("Updating elevator {ElevatorId}", elevator.Id); try { await _semaphore.WaitAsync(cancellationToken); try { - await Task.Yield(); // Ensure async context - // Create a deep copy of the elevator to ensure thread safety var elevatorCopy = elevator.Clone(); @@ -58,18 +81,39 @@ public async Task UpdateElevatorAsync(ElevatorItem elevator, Cancellatio { if (_elevators.TryUpdate(elevatorCopy.Id, elevatorCopy, existingElevator)) { + _logger.LogInformation("Elevator updated in memory {ElevatorId}", elevator.Id); + + // Update in database + using var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var dbElevator = await context.Elevators + .FirstOrDefaultAsync(e => e.Id == elevator.Id, cancellationToken); + + if (dbElevator != null) + { + // Update database entity with new values + dbElevator.CurrentFloor = elevator.CurrentFloor; + dbElevator.ElevatorStatus = elevator.ElevatorStatus; + dbElevator.ElevatorDirection = elevator.ElevatorDirection; + await context.SaveChangesAsync(cancellationToken); + } + return Result.Success(); } + _logger.LogWarning("Failed to update elevator {ElevatorId}", elevator.Id); return Result.Failure( new Error("UpdateElevator.ConcurrencyError", "Failed to update elevator due to concurrent modification", ErrorType.Conflict)); } + _logger.LogInformation("Adding elevator {ElevatorId}", elevator.Id); if (_elevators.TryAdd(elevatorCopy.Id, elevatorCopy)) { return Result.Success(); } + _logger.LogWarning("Failed to add elevator {ElevatorId}", elevator.Id); return Result.Failure( new Error("UpdateElevator.AddError", "Failed to add elevator to the pool", ErrorType.Failure)); } @@ -80,6 +124,7 @@ public async Task UpdateElevatorAsync(ElevatorItem elevator, Cancellatio } catch (Exception ex) { + _logger.LogError(ex, "Error updating elevator {ElevatorId}", elevator.Id); return Result.Failure( new Error("UpdateElevator.Error", ex.Message, ErrorType.Failure)); } @@ -88,12 +133,40 @@ public async Task UpdateElevatorAsync(ElevatorItem elevator, Cancellatio /// public async Task>> GetAllElevatorsAsync(Guid buildingId, CancellationToken cancellationToken) { + _logger.LogInformation("Getting all elevators in building {BuildingId}", buildingId); try { await _semaphore.WaitAsync(cancellationToken); try { - await Task.Yield(); // Ensure async context + // Always update from database if there are no elevators for this building + var hasElevatorsForBuilding = _elevators.Values.Any(e => e.BuildingId == buildingId); + var needsUpdate = DateTime.UtcNow - _lastUpdate > _updateInterval || !hasElevatorsForBuilding; + + if (needsUpdate) + { + _logger.LogInformation("Updating elevators from database for building {BuildingId}. LastUpdate: {LastUpdate}", buildingId, _lastUpdate); + + using var scope = _serviceProvider.CreateScope(); + var context = scope.ServiceProvider.GetRequiredService(); + + var elevators = await context.Elevators + .Where(e => e.BuildingId == buildingId) + .ToListAsync(cancellationToken); + + // Update only elevators for this building + foreach (var elevator in _elevators.Values.Where(e => e.BuildingId == buildingId).ToList()) + { + _elevators.TryRemove(elevator.Id, out _); + } + + foreach (var elevator in elevators) + { + _elevators.TryAdd(elevator.Id, ElevatorItem.FromElevator(elevator)); + } + + _lastUpdate = DateTime.UtcNow; + } // Create a deep copy of each elevator to ensure thread safety var allElevators = _elevators.Values @@ -101,6 +174,8 @@ public async Task>> GetAllElevatorsAsync(Guid b .Select(e => e.Clone()) .ToList(); + _logger.LogInformation("Returning {ElevatorCount} elevators in building {BuildingId}", + allElevators.Count, buildingId); return Result.Success>(allElevators); } finally @@ -110,13 +185,27 @@ public async Task>> GetAllElevatorsAsync(Guid b } catch (Exception ex) { + _logger.LogError(ex, "Error getting all elevators for building {BuildingId}", buildingId); return Result.Failure>( new Error("GetAllElevators.Error", ex.Message, ErrorType.Failure)); } } + private void Dispose(bool disposing) + { + if (!_disposed) + { + if (disposing) + { + _semaphore.Dispose(); + } + _disposed = true; + } + } + public void Dispose() { - _semaphore.Dispose(); + Dispose(true); + GC.SuppressFinalize(this); } } diff --git a/src/Infrastructure/DependencyInjection.cs b/src/Infrastructure/DependencyInjection.cs index 6e81fdd..3cf10ee 100644 --- a/src/Infrastructure/DependencyInjection.cs +++ b/src/Infrastructure/DependencyInjection.cs @@ -1,10 +1,13 @@ using System.Text; using Application.Abstractions.Authentication; using Application.Abstractions.Data; +using Application.Abstractions.Services; +using Application.Services; using Infrastructure.Authentication; using Infrastructure.Authorization; using Infrastructure.Database; using Infrastructure.Persistance.Interceptors; +using Infrastructure.Services; using Infrastructure.Time; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authorization; @@ -16,6 +19,8 @@ using Microsoft.IdentityModel.Tokens; using Domain.Common; using Infrastructure.Persistence.Database; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; namespace Infrastructure; @@ -31,10 +36,30 @@ public static IServiceCollection AddInfrastructure( .AddHealthChecks(configuration) .AddAuthenticationInternal(configuration) ; + + /// Run migrations for the EF Core database context. + public static async Task RunMigrationsAsync(this IHost host) + { + using var scope = host.Services.CreateScope(); + var logger = scope.ServiceProvider.GetRequiredService>(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + try + { + await dbContext.Database.MigrateAsync(); + logger.LogInformation($"Successfully migrated the database"); + } + catch (Exception ex) + { + logger.LogError(ex, $"An error occurred while migrating the database"); + throw; + } + return host; + } private static IServiceCollection AddServices(this IServiceCollection services) { services.AddSingleton(); + services.AddHostedService(); return services; } @@ -48,7 +73,7 @@ private static IServiceCollection AddDatabase(this IServiceCollection services, { services.AddDbContext(options => options - .UseInMemoryDatabase(Guid.NewGuid().ToString()) + .UseInMemoryDatabase("TestDb") .UseSnakeCaseNamingConvention() .AddInterceptors(services.BuildServiceProvider().GetServices()) .ConfigureWarnings(x => x.Ignore(InMemoryEventId.TransactionIgnoredWarning)) diff --git a/src/Infrastructure/Persistence/SeedData/ApplicationDbContextSeedData.cs b/src/Infrastructure/Persistence/SeedData/ApplicationDbContextSeedData.cs index eda55c9..28512eb 100644 --- a/src/Infrastructure/Persistence/SeedData/ApplicationDbContextSeedData.cs +++ b/src/Infrastructure/Persistence/SeedData/ApplicationDbContextSeedData.cs @@ -10,13 +10,11 @@ namespace Infrastructure.Persistence.SeedData; public static class ApplicationDbContextSeedData { - public static void SeedData(this ModelBuilder modelBuilder) + + public static List GetSeedUsers() { - // 379f3466-663c-4bdb-a81a-a6b27875d36f - // f801d0b8-801d-495c-aaa5-a4308fe0f020 - //Administrator user to manage the application - var users = new List + return new List { new() { @@ -24,28 +22,29 @@ public static void SeedData(this ModelBuilder modelBuilder) FirstName = "Admin", LastName = "Joe", Email = "admin@buiding.com", - PasswordHash = "55BC042899399B562DD4A363FD250A9014C045B900716FCDC074861EB69C344A-B44367BE2D0B037E31AEEE2649199100", //Admin123 + PasswordHash = + "55BC042899399B562DD4A363FD250A9014C045B900716FCDC074861EB69C344A-B44367BE2D0B037E31AEEE2649199100", //Admin123 } }; - - modelBuilder.Entity().HasData(users); - - //Building - var building = new List() + } + + public static List GetSeedBuildings() + { + return new List { new() { Id = Guid.Parse("e16e32e7-8db0-4536-b86e-f53e53cd7a0d"), Name = "Joe's Building", NumberOfFloors = 10, - CreatedByUserId = users.FirstOrDefault()!.Id + CreatedByUserId = GetSeedUsers().FirstOrDefault()!.Id } }; - - modelBuilder.Entity().HasData(building); - - //Elevators - var elevators = new List() + } + + public static List GetSeedElevators() + { + return new List { new() { @@ -55,8 +54,8 @@ public static void SeedData(this ModelBuilder modelBuilder) ElevatorDirection = ElevatorDirection.None, ElevatorStatus = ElevatorStatus.Active, ElevatorType = ElevatorType.Passenger, - BuildingId = building.FirstOrDefault()!.Id, - CreatedByUserId = users.FirstOrDefault()!.Id + BuildingId = GetSeedBuildings().FirstOrDefault()!.Id, + CreatedByUserId = GetSeedUsers().FirstOrDefault()!.Id }, new() { @@ -66,8 +65,8 @@ public static void SeedData(this ModelBuilder modelBuilder) ElevatorDirection = ElevatorDirection.None, ElevatorStatus = ElevatorStatus.Active, ElevatorType = ElevatorType.Passenger, - BuildingId = building.FirstOrDefault()!.Id, - CreatedByUserId = users.FirstOrDefault()!.Id + BuildingId = GetSeedBuildings().FirstOrDefault()!.Id, + CreatedByUserId = GetSeedUsers().FirstOrDefault()!.Id }, new() { @@ -77,8 +76,8 @@ public static void SeedData(this ModelBuilder modelBuilder) ElevatorDirection = ElevatorDirection.None, ElevatorStatus = ElevatorStatus.Active, ElevatorType = ElevatorType.Passenger, - BuildingId = building.FirstOrDefault()!.Id, - CreatedByUserId = users.FirstOrDefault()!.Id + BuildingId = GetSeedBuildings().FirstOrDefault()!.Id, + CreatedByUserId = GetSeedUsers().FirstOrDefault()!.Id }, new() { @@ -88,8 +87,8 @@ public static void SeedData(this ModelBuilder modelBuilder) ElevatorDirection = ElevatorDirection.None, ElevatorStatus = ElevatorStatus.Active, ElevatorType = ElevatorType.Passenger, - BuildingId = building.FirstOrDefault()!.Id, - CreatedByUserId = users.FirstOrDefault()!.Id + BuildingId = GetSeedBuildings().FirstOrDefault()!.Id, + CreatedByUserId = GetSeedUsers().FirstOrDefault()!.Id }, new() { @@ -98,25 +97,17 @@ public static void SeedData(this ModelBuilder modelBuilder) Number = 5, ElevatorDirection = ElevatorDirection.None, ElevatorStatus = ElevatorStatus.Active, - ElevatorType = ElevatorType.Service, - BuildingId = building.FirstOrDefault()!.Id, - CreatedByUserId = users.FirstOrDefault()!.Id - }, - new() - { - Id = Guid.Parse("82d562f7-f7d5-4088-b735-9a7b085968d3"), - CurrentFloor = 1, - Number = 6, - ElevatorDirection = ElevatorDirection.None, - ElevatorStatus = ElevatorStatus.Active, - ElevatorType = ElevatorType.HighSpeed, - Speed = 1.0, - Capacity = 5, - BuildingId = building.FirstOrDefault()!.Id, - CreatedByUserId = users.FirstOrDefault()!.Id + ElevatorType = ElevatorType.Passenger, + BuildingId = GetSeedBuildings().FirstOrDefault()!.Id, + CreatedByUserId = GetSeedUsers().FirstOrDefault()!.Id } }; - - modelBuilder.Entity().HasData(elevators); + } + + public static void SeedData(this ModelBuilder modelBuilder) + { + modelBuilder.Entity().HasData(GetSeedUsers()); + modelBuilder.Entity().HasData(GetSeedBuildings()); + modelBuilder.Entity().HasData(GetSeedElevators()); } } diff --git a/src/Infrastructure/Services/ElevatorSimulationHostedService.cs b/src/Infrastructure/Services/ElevatorSimulationHostedService.cs new file mode 100644 index 0000000..fcd3745 --- /dev/null +++ b/src/Infrastructure/Services/ElevatorSimulationHostedService.cs @@ -0,0 +1,76 @@ +using Application.Abstractions.Services; +using Domain.Elevators; +using Infrastructure.Persistence.SeedData; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +namespace Infrastructure.Services; + +/// Background service for simulating elevator operations. +public class ElevatorSimulationHostedService( + ILogger logger, + IInMemoryElevatorPoolService elevatorPoolService) + : BackgroundService +{ + private readonly TimeSpan _simulationInterval = TimeSpan.FromSeconds(1); + + //ToDo: This should be configurable + private readonly Guid _buildingId = ApplicationDbContextSeedData.GetSeedBuildings()!.FirstOrDefault()!.Id; + + protected override async Task ExecuteAsync(CancellationToken stoppingToken) + { + logger.LogInformation("Elevator simulation service is starting."); + + while (!stoppingToken.IsCancellationRequested) + { + try + { + // Get all the elevators based on the building id + var elevatorsResult = await elevatorPoolService.GetAllElevatorsAsync(_buildingId, stoppingToken); + if (elevatorsResult.IsFailure) + { + logger.LogWarning("Failed to get elevators: {Error}", elevatorsResult.Error); + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); // Back off on error + continue; + } + + foreach (var elevator in elevatorsResult.Value) + { + if (elevator.ElevatorStatus != ElevatorStatus.Active) + { + return; + } + + // Simulate elevator movement based on direction + // ToDo: Implement logic for elevator movement taking into account speed + switch (elevator.ElevatorDirection) + { + case ElevatorDirection.Up: + elevator.CurrentFloor++; + break; + case ElevatorDirection.Down: + elevator.CurrentFloor--; + break; + } + + // Update elevator state with new floor + await elevatorPoolService.UpdateElevatorAsync( + elevator, stoppingToken); + + logger.LogInformation( + "Elevator {ElevatorId} moved to floor {Floor}", + elevator.Id,elevator); + } + + await Task.Delay(_simulationInterval, stoppingToken); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogError(ex, "An error occurred while simulating elevator movements"); + await Task.Delay(TimeSpan.FromSeconds(5), stoppingToken); // Back off on error + } + } + } + +} diff --git a/src/Presentation/DependencyInjections.cs b/src/Presentation/DependencyInjections.cs index 29d36d6..d9a0e71 100644 --- a/src/Presentation/DependencyInjections.cs +++ b/src/Presentation/DependencyInjections.cs @@ -23,22 +23,5 @@ public static IServiceCollection AddScreens( return services; } - /// Run migrations for the EF Core database context. - public static async Task RunMigrationsAsync(this IHost host) - { - using var scope = host.Services.CreateScope(); - var logger = scope.ServiceProvider.GetRequiredService>(); - var dbContext = scope.ServiceProvider.GetRequiredService(); - try - { - await dbContext.Database.MigrateAsync(); - logger.LogInformation($"Successfully migrated the database"); - } - catch (Exception ex) - { - logger.LogError(ex, $"An error occurred while migrating the database"); - throw; - } - return host; - } + } diff --git a/tests/ApplicationTests/ElevatorPools/ElevatorPoolsTests.cs b/tests/ApplicationTests/ElevatorPools/ElevatorPoolsTests.cs new file mode 100644 index 0000000..9d649ab --- /dev/null +++ b/tests/ApplicationTests/ElevatorPools/ElevatorPoolsTests.cs @@ -0,0 +1,106 @@ +using Application; +using Application.Abstractions.Data; +using Application.Abstractions.Services; +using Domain.Users; +using Domain.Elevators; +using Infrastructure; +using Infrastructure.Migrations; +using Infrastructure.Persistence.Database; +using Infrastructure.Persistence.SeedData; +using Infrastructure.Services; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Xunit.Sdk; + +namespace ApplicationTests.ElevatorPools; + +public class ElevatorPoolsTests +{ + [Fact] + public void CanDiElevatorPoolService() + { + var host = new HostBuilder() + .ConfigureServices((hostContext, services) => + { + services.AddApplication(); + services.AddInfrastructure(hostContext.Configuration, + true); + }) + .Build(); + + var elevatorPoolService = host.Services.GetRequiredService(); + Assert.NotNull(elevatorPoolService); + } + + [Fact] + public async Task CanGetAllElevators() + { + // Arrange + var host = new HostBuilder() + .ConfigureServices((hostContext, services) => + { + services.AddApplication(); + services.AddInfrastructure(hostContext.Configuration, + true); + }) + .Build(); + + // Get the service first since it's a singleton + var elevatorPoolService = host.Services.GetRequiredService(); + + // Then create a scope for database operations + using var scope = host.Services.CreateScope(); + await SeedInMemoryDatabase(scope); + + // Act + var buildingId = ApplicationDbContextSeedData.GetSeedBuildings().First().Id; + var result = await elevatorPoolService.GetAllElevatorsAsync(buildingId, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.True(result.IsSuccess); + var elevators = result.Value.ToList(); + Assert.NotEmpty(elevators); + Assert.Equal(5, elevators.Count); + + // Verify elevator properties + foreach (var elevator in elevators) + { + Assert.Equal(buildingId, elevator.BuildingId); + Assert.Equal(ElevatorStatus.Active, elevator.ElevatorStatus); + Assert.Equal(ElevatorDirection.None, elevator.ElevatorDirection); + Assert.Equal(1, elevator.CurrentFloor); + } + } + + internal async Task SeedInMemoryDatabase(IServiceScope scope) + { + var dbContext = scope.ServiceProvider.GetRequiredService(); + + // Clear existing data + dbContext.Users.RemoveRange(dbContext.Users); + dbContext.Buildings.RemoveRange(dbContext.Buildings); + dbContext.Elevators.RemoveRange(dbContext.Elevators); + await dbContext.SaveChangesAsync(CancellationToken.None); + + // Add seed data + foreach (User seedUser in ApplicationDbContextSeedData.GetSeedUsers()) + { + dbContext.Users.Add(seedUser); + } + await dbContext.SaveChangesAsync(CancellationToken.None); + + foreach (Domain.Buildings.Building seedBuilding in ApplicationDbContextSeedData.GetSeedBuildings()) + { + dbContext.Buildings.Add(seedBuilding); + } + await dbContext.SaveChangesAsync(CancellationToken.None); + + foreach (Domain.Elevators.Elevator seedElevator in ApplicationDbContextSeedData.GetSeedElevators()) + { + dbContext.Elevators.Add(seedElevator); + } + await dbContext.SaveChangesAsync(CancellationToken.None); + } +} diff --git a/tests/InfrastructureTests/BuildingManagementTests.cs b/tests/InfrastructureTests/BuildingManagementTests.cs index 5bedf29..b4de966 100644 --- a/tests/InfrastructureTests/BuildingManagementTests.cs +++ b/tests/InfrastructureTests/BuildingManagementTests.cs @@ -11,11 +11,6 @@ namespace InfrastructureTests.Building; public class BuildingManagementTests { - public BuildingManagementTests() - { - - } - [Fact] public async Task CantCreateBuildingWithoutUser() {