diff --git a/src/OrderMonitor.Api/Controllers/DiagnosticsController.cs b/src/OrderMonitor.Api/Controllers/DiagnosticsController.cs index 34a364d..6fe0dac 100644 --- a/src/OrderMonitor.Api/Controllers/DiagnosticsController.cs +++ b/src/OrderMonitor.Api/Controllers/DiagnosticsController.cs @@ -1,4 +1,3 @@ -using Dapper; using Microsoft.AspNetCore.Mvc; using OrderMonitor.Core.Interfaces; @@ -12,12 +11,12 @@ namespace OrderMonitor.Api.Controllers; [Produces("application/json")] public class DiagnosticsController : ControllerBase { - private readonly IDbConnectionFactory _connectionFactory; + private readonly IDiagnosticsService _diagnosticsService; private readonly ILogger _logger; - public DiagnosticsController(IDbConnectionFactory connectionFactory, ILogger logger) + public DiagnosticsController(IDiagnosticsService diagnosticsService, ILogger logger) { - _connectionFactory = connectionFactory; + _diagnosticsService = diagnosticsService; _logger = logger; } @@ -29,9 +28,7 @@ public async Task GetOrderTables() { try { - using var conn = _connectionFactory.CreateConnection(); - var tables = await conn.QueryAsync( - "SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME LIKE '%Order%' ORDER BY TABLE_NAME"); + var tables = await _diagnosticsService.GetTablesAsync("Order"); return Ok(tables); } catch (Exception ex) @@ -48,13 +45,7 @@ public async Task GetTableColumns(string tableName) { try { - using var conn = _connectionFactory.CreateConnection(); - var columns = await conn.QueryAsync( - @"SELECT COLUMN_NAME, DATA_TYPE, IS_NULLABLE, CHARACTER_MAXIMUM_LENGTH - FROM INFORMATION_SCHEMA.COLUMNS - WHERE TABLE_NAME = @TableName - ORDER BY ORDINAL_POSITION", - new { TableName = tableName }); + var columns = await _diagnosticsService.GetColumnsAsync(tableName); return Ok(columns); } catch (Exception ex) @@ -78,9 +69,8 @@ public async Task RunQuery([FromBody] QueryRequest request) try { - using var conn = _connectionFactory.CreateConnection(); - var results = await conn.QueryAsync(request.Sql); - return Ok(results.Take(100)); // Limit to 100 rows + var results = await _diagnosticsService.ExecuteQueryAsync(request.Sql); + return Ok(results); } catch (Exception ex) { diff --git a/src/OrderMonitor.Core/Interfaces/IDbConnectionFactory.cs b/src/OrderMonitor.Core/Interfaces/IDbConnectionFactory.cs deleted file mode 100644 index 187c004..0000000 --- a/src/OrderMonitor.Core/Interfaces/IDbConnectionFactory.cs +++ /dev/null @@ -1,14 +0,0 @@ -using System.Data; - -namespace OrderMonitor.Core.Interfaces; - -/// -/// Factory interface for creating database connections. -/// -public interface IDbConnectionFactory -{ - /// - /// Creates a new database connection. - /// - IDbConnection CreateConnection(); -} diff --git a/src/OrderMonitor.Core/Interfaces/IDiagnosticsService.cs b/src/OrderMonitor.Core/Interfaces/IDiagnosticsService.cs new file mode 100644 index 0000000..5a83bd4 --- /dev/null +++ b/src/OrderMonitor.Core/Interfaces/IDiagnosticsService.cs @@ -0,0 +1,33 @@ +namespace OrderMonitor.Core.Interfaces; + +/// +/// Service interface for database diagnostics and schema discovery. +/// +public interface IDiagnosticsService +{ + /// + /// Gets table names matching a pattern. + /// + Task> GetTablesAsync(string pattern, CancellationToken cancellationToken = default); + + /// + /// Gets column information for a specific table. + /// + Task> GetColumnsAsync(string tableName, CancellationToken cancellationToken = default); + + /// + /// Executes a read-only query and returns results. + /// + Task>> ExecuteQueryAsync(string sql, CancellationToken cancellationToken = default); +} + +/// +/// Represents column metadata for diagnostics. +/// +public class ColumnInfo +{ + public string ColumnName { get; set; } = string.Empty; + public string DataType { get; set; } = string.Empty; + public string IsNullable { get; set; } = string.Empty; + public int? CharacterMaximumLength { get; set; } +} diff --git a/src/OrderMonitor.Infrastructure/Data/DatabaseProviderFactory.cs b/src/OrderMonitor.Infrastructure/Data/DatabaseProviderFactory.cs new file mode 100644 index 0000000..e840e04 --- /dev/null +++ b/src/OrderMonitor.Infrastructure/Data/DatabaseProviderFactory.cs @@ -0,0 +1,93 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; + +namespace OrderMonitor.Infrastructure.Data; + +/// +/// Supported database providers for multi-database support. +/// +public enum DatabaseProvider +{ + SqlServer, + MySql, + PostgreSql +} + +/// +/// Factory for configuring the DbContext with the appropriate database provider. +/// +public static class DatabaseProviderFactory +{ + private static readonly HashSet ValidProviders = new(StringComparer.OrdinalIgnoreCase) + { + "SqlServer", "MySql", "PostgreSql" + }; + + /// + /// Parses a provider string into a DatabaseProvider enum value. + /// + public static DatabaseProvider ParseProvider(string provider) + { + if (string.IsNullOrWhiteSpace(provider)) + throw new ArgumentException("Database provider cannot be null or empty.", nameof(provider)); + + return provider.Trim().ToLowerInvariant() switch + { + "sqlserver" => DatabaseProvider.SqlServer, + "mysql" => DatabaseProvider.MySql, + "postgresql" or "postgres" => DatabaseProvider.PostgreSql, + _ => throw new ArgumentException( + $"Invalid database provider '{provider}'. Allowed values: {string.Join(", ", ValidProviders)}.", + nameof(provider)) + }; + } + + /// + /// Adds the OrderMonitorDbContext to the service collection with the specified provider. + /// + public static IServiceCollection AddOrderMonitorDbContext( + this IServiceCollection services, + DatabaseProvider provider, + string connectionString) + { + if (string.IsNullOrWhiteSpace(connectionString)) + throw new ArgumentException("Connection string cannot be null or empty.", nameof(connectionString)); + + services.AddDbContext(options => + { + ConfigureProvider(options, provider, connectionString); + options.UseQueryTrackingBehavior(QueryTrackingBehavior.NoTracking); + }); + + return services; + } + + /// + /// Configures the DbContextOptionsBuilder for the specified provider. + /// + public static void ConfigureProvider( + DbContextOptionsBuilder options, + DatabaseProvider provider, + string connectionString) + { + switch (provider) + { + case DatabaseProvider.SqlServer: + options.UseSqlServer(connectionString); + break; + + case DatabaseProvider.MySql: + var serverVersion = ServerVersion.AutoDetect(connectionString); + options.UseMySql(connectionString, serverVersion); + break; + + case DatabaseProvider.PostgreSql: + options.UseNpgsql(connectionString); + break; + + default: + throw new ArgumentOutOfRangeException(nameof(provider), provider, + $"Unsupported database provider: {provider}"); + } + } +} diff --git a/src/OrderMonitor.Infrastructure/Data/DiagnosticsService.cs b/src/OrderMonitor.Infrastructure/Data/DiagnosticsService.cs new file mode 100644 index 0000000..cd40b6e --- /dev/null +++ b/src/OrderMonitor.Infrastructure/Data/DiagnosticsService.cs @@ -0,0 +1,92 @@ +using Microsoft.EntityFrameworkCore; +using OrderMonitor.Core.Interfaces; + +namespace OrderMonitor.Infrastructure.Data; + +/// +/// EF Core implementation of database diagnostics. +/// Uses raw SQL for schema introspection queries. +/// +public class DiagnosticsService : IDiagnosticsService +{ + private readonly OrderMonitorDbContext _dbContext; + + public DiagnosticsService(OrderMonitorDbContext dbContext) + { + _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); + } + + /// + public async Task> GetTablesAsync( + string pattern, + CancellationToken cancellationToken = default) + { + // Use INFORMATION_SCHEMA which is supported by SQL Server, MySQL, and PostgreSQL + var tables = await _dbContext.Database + .SqlQueryRaw( + "SELECT TABLE_NAME AS Value FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_NAME LIKE {0} ORDER BY TABLE_NAME", + $"%{pattern}%") + .ToListAsync(cancellationToken); + + return tables; + } + + /// + public async Task> GetColumnsAsync( + string tableName, + CancellationToken cancellationToken = default) + { + var columns = await _dbContext.Database + .SqlQueryRaw( + @"SELECT COLUMN_NAME AS ColumnName, DATA_TYPE AS DataType, + IS_NULLABLE AS IsNullable, CHARACTER_MAXIMUM_LENGTH AS CharacterMaximumLength + FROM INFORMATION_SCHEMA.COLUMNS + WHERE TABLE_NAME = {0} + ORDER BY ORDINAL_POSITION", + tableName) + .ToListAsync(cancellationToken); + + return columns; + } + + /// + public async Task>> ExecuteQueryAsync( + string sql, + CancellationToken cancellationToken = default) + { + var results = new List>(); + + var connection = _dbContext.Database.GetDbConnection(); + await connection.OpenAsync(cancellationToken); + + try + { + using var command = connection.CreateCommand(); + command.CommandText = sql; + + using var reader = await command.ExecuteReaderAsync(cancellationToken); + var columnNames = Enumerable.Range(0, reader.FieldCount) + .Select(reader.GetName) + .ToList(); + + var count = 0; + while (await reader.ReadAsync(cancellationToken) && count < 100) + { + var row = new Dictionary(); + foreach (var col in columnNames) + { + var value = reader[col]; + row[col] = value == DBNull.Value ? null : value; + } + results.Add(row); + count++; + } + } + finally + { + await connection.CloseAsync(); + } + + return results; + } +} diff --git a/src/OrderMonitor.Infrastructure/Data/EfCoreOrderRepository.cs b/src/OrderMonitor.Infrastructure/Data/EfCoreOrderRepository.cs new file mode 100644 index 0000000..1faa14a --- /dev/null +++ b/src/OrderMonitor.Infrastructure/Data/EfCoreOrderRepository.cs @@ -0,0 +1,223 @@ +using Microsoft.EntityFrameworkCore; +using OrderMonitor.Core.Configuration; +using OrderMonitor.Core.Interfaces; +using OrderMonitor.Core.Models; + +namespace OrderMonitor.Infrastructure.Data; + +/// +/// Repository implementation for order data access using Entity Framework Core. +/// All date/time calculations are performed in C# for database-agnostic operation. +/// +public class EfCoreOrderRepository : IOrderRepository +{ + private readonly OrderMonitorDbContext _dbContext; + + public EfCoreOrderRepository(OrderMonitorDbContext dbContext) + { + _dbContext = dbContext ?? throw new ArgumentNullException(nameof(dbContext)); + } + + /// + public async Task> GetStuckOrdersAsync( + StuckOrderQueryParams queryParams, + CancellationToken cancellationToken = default) + { + var utcNow = DateTime.UtcNow; + var twoYearsAgo = utcNow.AddYears(-2); + + // Fetch candidate orders with all related data using LINQ joins + var query = from opt in _dbContext.OrderProductTrackings + join co in _dbContext.ConsolidationOrders on opt.CONumber equals co.CONumber + join st in _dbContext.TrackingStatuses on opt.Status equals st.TrackingStatusId + join sn in _dbContext.SnSpecifications on opt.OptSnSpId equals sn.SnId + join mt in _dbContext.MajorProductTypes on sn.MasterProductTypeId equals mt.MProductTypeId + join pm in _dbContext.Partners on opt.TPartnerCode equals pm.PartnerId into partners + from pm in partners.Where(p => p.IsActive).DefaultIfEmpty() + where opt.IsPrimaryComponent + && opt.OrderDate > twoYearsAgo + && opt.Status < 6400 + && opt.LastUpdatedDate != null + select new + { + co.CONumber, + co.OrderNumber, + opt.Status, + StatusName = st.TrackingStatusName, + ProductType = mt.MajorProductTypeName, + opt.LastUpdatedDate, + co.WebsiteCode, + FacilityCode = opt.TPartnerCode, + FacilityName = pm != null ? pm.PartnerDisplayName : null + }; + + // Materialize to apply C# date logic + var rawOrders = await query.ToListAsync(cancellationToken); + + // Apply stuck threshold filtering in C# (replaces SQL DATEDIFF) + var stuckOrders = rawOrders + .Where(o => + { + var hoursStuck = (int)(utcNow - o.LastUpdatedDate!.Value).TotalHours; + return (o.Status >= OrderStatusConfiguration.PrepMinStatusId + && o.Status <= OrderStatusConfiguration.PrepMaxStatusId + && hoursStuck > OrderStatusConfiguration.PrepThresholdHours) + || (o.Status >= OrderStatusConfiguration.FacilityMinStatusId + && o.Status <= OrderStatusConfiguration.FacilityMaxStatusId + && hoursStuck > OrderStatusConfiguration.FacilityThresholdHours); + }) + // Deduplicate by CONumber (keep latest per order) - replaces ROW_NUMBER() + .GroupBy(o => o.CONumber) + .Select(g => g.OrderByDescending(o => o.LastUpdatedDate).First()) + .Select(o => new StuckOrderDto + { + OrderId = o.CONumber, + OrderNumber = o.OrderNumber ?? string.Empty, + StatusId = o.Status, + Status = o.StatusName ?? string.Empty, + ProductType = o.ProductType ?? string.Empty, + StuckSince = o.LastUpdatedDate!.Value, + HoursStuck = (int)(utcNow - o.LastUpdatedDate!.Value).TotalHours, + ThresholdHours = OrderStatusConfiguration.GetThresholdHours(o.Status), + Region = o.WebsiteCode, + CustomerEmail = null, + FacilityCode = o.FacilityCode?.ToString(), + FacilityName = o.FacilityName ?? "Unknown" + }); + + // Apply optional filters + if (queryParams.StatusId.HasValue) + stuckOrders = stuckOrders.Where(o => o.StatusId == queryParams.StatusId.Value); + + if (!string.IsNullOrWhiteSpace(queryParams.Status)) + stuckOrders = stuckOrders.Where(o => + o.Status.Contains(queryParams.Status, StringComparison.OrdinalIgnoreCase)); + + if (queryParams.MinHours.HasValue) + stuckOrders = stuckOrders.Where(o => o.HoursStuck >= queryParams.MinHours.Value); + + if (queryParams.MaxHours.HasValue) + stuckOrders = stuckOrders.Where(o => o.HoursStuck <= queryParams.MaxHours.Value); + + // Order and paginate + return stuckOrders + .OrderByDescending(o => o.HoursStuck) + .Skip(queryParams.Offset) + .Take(queryParams.Limit) + .ToList(); + } + + /// + public async Task GetStuckOrdersCountAsync(CancellationToken cancellationToken = default) + { + var utcNow = DateTime.UtcNow; + var twoYearsAgo = utcNow.AddYears(-2); + + // Fetch candidate records + var query = _dbContext.OrderProductTrackings + .Where(opt => opt.IsPrimaryComponent + && opt.OrderDate > twoYearsAgo + && opt.Status < 6400 + && opt.LastUpdatedDate != null); + + var candidates = await query + .Select(opt => new { opt.CONumber, opt.Status, opt.LastUpdatedDate }) + .ToListAsync(cancellationToken); + + // Apply stuck threshold filtering in C# and count distinct CONumbers + return candidates + .Where(o => + { + var hoursStuck = (int)(utcNow - o.LastUpdatedDate!.Value).TotalHours; + return (o.Status >= OrderStatusConfiguration.PrepMinStatusId + && o.Status <= OrderStatusConfiguration.PrepMaxStatusId + && hoursStuck > OrderStatusConfiguration.PrepThresholdHours) + || (o.Status >= OrderStatusConfiguration.FacilityMinStatusId + && o.Status <= OrderStatusConfiguration.FacilityMaxStatusId + && hoursStuck > OrderStatusConfiguration.FacilityThresholdHours); + }) + .Select(o => o.CONumber) + .Distinct() + .Count(); + } + + /// + public async Task> GetOrderStatusHistoryAsync( + string orderId, + CancellationToken cancellationToken = default) + { + var utcNow = DateTime.UtcNow; + + // Fetch status history ordered by timestamp + var statusEntries = await _dbContext.OrderProductTrackings + .Where(opt => opt.CONumber == orderId && opt.IsPrimaryComponent && opt.LastUpdatedDate != null) + .Join(_dbContext.TrackingStatuses, + opt => opt.Status, + st => st.TrackingStatusId, + (opt, st) => new + { + StatusId = opt.Status, + Status = st.TrackingStatusName ?? string.Empty, + Timestamp = opt.LastUpdatedDate!.Value + }) + .OrderBy(x => x.Timestamp) + .ToListAsync(cancellationToken); + + // Apply LEAD() equivalent in C# - calculate duration between consecutive statuses + var result = new List(); + for (var i = 0; i < statusEntries.Count; i++) + { + var entry = statusEntries[i]; + var nextTimestamp = i < statusEntries.Count - 1 + ? statusEntries[i + 1].Timestamp + : (DateTime?)null; + + var isStuck = false; + string duration; + + if (nextTimestamp == null) + { + // Last/current status - calculate from timestamp to now + var hoursFromNow = (int)(utcNow - entry.Timestamp).TotalHours; + var isThresholdExceeded = + (entry.StatusId >= OrderStatusConfiguration.PrepMinStatusId + && entry.StatusId <= OrderStatusConfiguration.PrepMaxStatusId + && hoursFromNow > OrderStatusConfiguration.PrepThresholdHours) + || (entry.StatusId >= OrderStatusConfiguration.FacilityMinStatusId + && entry.StatusId <= OrderStatusConfiguration.FacilityMaxStatusId + && hoursFromNow > OrderStatusConfiguration.FacilityThresholdHours); + + isStuck = isThresholdExceeded; + duration = isThresholdExceeded + ? $"{hoursFromNow}h+ (STUCK)" + : $"{hoursFromNow}h (Current)"; + } + else + { + // Calculate duration between this status and the next + var totalMinutes = (int)(nextTimestamp.Value - entry.Timestamp).TotalMinutes; + if (totalMinutes < 60) + { + duration = $"{totalMinutes}m"; + } + else + { + var hours = totalMinutes / 60; + var minutes = totalMinutes % 60; + duration = $"{hours}h {minutes}m"; + } + } + + result.Add(new OrderStatusHistoryDto + { + StatusId = entry.StatusId, + Status = entry.Status, + Timestamp = entry.Timestamp, + Duration = duration, + IsStuck = isStuck + }); + } + + return result; + } +} diff --git a/src/OrderMonitor.Infrastructure/Data/Entities/ConsolidationOrderEntity.cs b/src/OrderMonitor.Infrastructure/Data/Entities/ConsolidationOrderEntity.cs new file mode 100644 index 0000000..61f1613 --- /dev/null +++ b/src/OrderMonitor.Infrastructure/Data/Entities/ConsolidationOrderEntity.cs @@ -0,0 +1,26 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OrderMonitor.Infrastructure.Data.Entities; + +/// +/// Entity mapping for the ConsolidationOrder table. +/// +[Table("ConsolidationOrder")] +public class ConsolidationOrderEntity +{ + [Key] + [Column("CONumber")] + [StringLength(50)] + public string CONumber { get; set; } = string.Empty; + + [Column("orderNumber")] + [StringLength(50)] + public string? OrderNumber { get; set; } + + [Column("websiteCode")] + [StringLength(20)] + public string? WebsiteCode { get; set; } + + public ICollection OrderProductTrackings { get; set; } = []; +} diff --git a/src/OrderMonitor.Infrastructure/Data/Entities/MajorProductTypeEntity.cs b/src/OrderMonitor.Infrastructure/Data/Entities/MajorProductTypeEntity.cs new file mode 100644 index 0000000..e0e29e4 --- /dev/null +++ b/src/OrderMonitor.Infrastructure/Data/Entities/MajorProductTypeEntity.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OrderMonitor.Infrastructure.Data.Entities; + +/// +/// Entity mapping for the luk_MajorProductType table. +/// +[Table("luk_MajorProductType")] +public class MajorProductTypeEntity +{ + [Key] + [Column("MProductTypeID")] + public int MProductTypeId { get; set; } + + [Column("MajorProductTypeName")] + [StringLength(100)] + public string? MajorProductTypeName { get; set; } +} diff --git a/src/OrderMonitor.Infrastructure/Data/Entities/OrderProductTrackingEntity.cs b/src/OrderMonitor.Infrastructure/Data/Entities/OrderProductTrackingEntity.cs new file mode 100644 index 0000000..64dd26f --- /dev/null +++ b/src/OrderMonitor.Infrastructure/Data/Entities/OrderProductTrackingEntity.cs @@ -0,0 +1,50 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OrderMonitor.Infrastructure.Data.Entities; + +/// +/// Entity mapping for the OrderProductTracking table. +/// +[Table("OrderProductTracking")] +public class OrderProductTrackingEntity +{ + [Key] + [Column("OPT_ID")] + public long Id { get; set; } + + [Column("CONumber")] + [StringLength(50)] + public string CONumber { get; set; } = string.Empty; + + [Column("Status")] + public int Status { get; set; } + + [Column("lastUpdatedDate")] + public DateTime? LastUpdatedDate { get; set; } + + [Column("isPrimaryComponent")] + public bool IsPrimaryComponent { get; set; } + + [Column("TPartnerCode")] + public int? TPartnerCode { get; set; } + + [Column("OPT_SnSpId")] + public int? OptSnSpId { get; set; } + + [Column("OrderDate")] + public DateTime? OrderDate { get; set; } + + // Navigation properties + [ForeignKey("CONumber")] + public ConsolidationOrderEntity? ConsolidationOrder { get; set; } + + [ForeignKey("Status")] + public TrackingStatusEntity? TrackingStatus { get; set; } + + [ForeignKey("OptSnSpId")] + public SnSpecificationEntity? SnSpecification { get; set; } + + [ForeignKey("TPartnerCode")] + public PartnerEntity? Partner { get; set; } +} diff --git a/src/OrderMonitor.Infrastructure/Data/Entities/PartnerEntity.cs b/src/OrderMonitor.Infrastructure/Data/Entities/PartnerEntity.cs new file mode 100644 index 0000000..83a9699 --- /dev/null +++ b/src/OrderMonitor.Infrastructure/Data/Entities/PartnerEntity.cs @@ -0,0 +1,22 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OrderMonitor.Infrastructure.Data.Entities; + +/// +/// Entity mapping for the Partner_Master table. +/// +[Table("Partner_Master")] +public class PartnerEntity +{ + [Key] + [Column("PartnerID")] + public int PartnerId { get; set; } + + [Column("PartnerDisplayName")] + [StringLength(100)] + public string? PartnerDisplayName { get; set; } + + [Column("IsActive")] + public bool IsActive { get; set; } +} diff --git a/src/OrderMonitor.Infrastructure/Data/Entities/SnSpecificationEntity.cs b/src/OrderMonitor.Infrastructure/Data/Entities/SnSpecificationEntity.cs new file mode 100644 index 0000000..e5e7daa --- /dev/null +++ b/src/OrderMonitor.Infrastructure/Data/Entities/SnSpecificationEntity.cs @@ -0,0 +1,21 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OrderMonitor.Infrastructure.Data.Entities; + +/// +/// Entity mapping for the mas_SnSpecification table. +/// +[Table("mas_SnSpecification")] +public class SnSpecificationEntity +{ + [Key] + [Column("SnID")] + public int SnId { get; set; } + + [Column("MasterProductTypeID")] + public int? MasterProductTypeId { get; set; } + + [ForeignKey("MasterProductTypeId")] + public MajorProductTypeEntity? MajorProductType { get; set; } +} diff --git a/src/OrderMonitor.Infrastructure/Data/Entities/TrackingStatusEntity.cs b/src/OrderMonitor.Infrastructure/Data/Entities/TrackingStatusEntity.cs new file mode 100644 index 0000000..6340d7e --- /dev/null +++ b/src/OrderMonitor.Infrastructure/Data/Entities/TrackingStatusEntity.cs @@ -0,0 +1,19 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace OrderMonitor.Infrastructure.Data.Entities; + +/// +/// Entity mapping for the luk_Tracking_Status table. +/// +[Table("luk_Tracking_Status")] +public class TrackingStatusEntity +{ + [Key] + [Column("Tracking_Status_id")] + public int TrackingStatusId { get; set; } + + [Column("Tracking_Status_Name")] + [StringLength(100)] + public string? TrackingStatusName { get; set; } +} diff --git a/src/OrderMonitor.Infrastructure/Data/OrderMonitorDbContext.cs b/src/OrderMonitor.Infrastructure/Data/OrderMonitorDbContext.cs new file mode 100644 index 0000000..e1e26bd --- /dev/null +++ b/src/OrderMonitor.Infrastructure/Data/OrderMonitorDbContext.cs @@ -0,0 +1,93 @@ +using Microsoft.EntityFrameworkCore; +using OrderMonitor.Infrastructure.Data.Entities; + +namespace OrderMonitor.Infrastructure.Data; + +/// +/// Entity Framework Core database context for the Order Monitor system. +/// Supports SQL Server, MySQL, and PostgreSQL providers. +/// +public class OrderMonitorDbContext : DbContext +{ + public OrderMonitorDbContext(DbContextOptions options) + : base(options) + { + } + + public DbSet ConsolidationOrders => Set(); + public DbSet OrderProductTrackings => Set(); + public DbSet TrackingStatuses => Set(); + public DbSet SnSpecifications => Set(); + public DbSet MajorProductTypes => Set(); + public DbSet Partners => Set(); + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // ConsolidationOrder + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.CONumber); + entity.HasMany(e => e.OrderProductTrackings) + .WithOne(e => e.ConsolidationOrder) + .HasForeignKey(e => e.CONumber) + .OnDelete(DeleteBehavior.NoAction); + }); + + // OrderProductTracking + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.Id); + + entity.HasOne(e => e.TrackingStatus) + .WithMany() + .HasForeignKey(e => e.Status) + .OnDelete(DeleteBehavior.NoAction); + + entity.HasOne(e => e.SnSpecification) + .WithMany() + .HasForeignKey(e => e.OptSnSpId) + .OnDelete(DeleteBehavior.NoAction); + + entity.HasOne(e => e.Partner) + .WithMany() + .HasForeignKey(e => e.TPartnerCode) + .OnDelete(DeleteBehavior.NoAction); + + entity.HasIndex(e => e.CONumber); + entity.HasIndex(e => e.Status); + entity.HasIndex(e => e.IsPrimaryComponent); + entity.HasIndex(e => e.OrderDate); + }); + + // TrackingStatus + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.TrackingStatusId); + }); + + // SnSpecification + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.SnId); + + entity.HasOne(e => e.MajorProductType) + .WithMany() + .HasForeignKey(e => e.MasterProductTypeId) + .OnDelete(DeleteBehavior.NoAction); + }); + + // MajorProductType + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.MProductTypeId); + }); + + // Partner + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.PartnerId); + }); + } +} diff --git a/src/OrderMonitor.Infrastructure/Data/OrderRepository.cs b/src/OrderMonitor.Infrastructure/Data/OrderRepository.cs deleted file mode 100644 index 5d5d6e4..0000000 --- a/src/OrderMonitor.Infrastructure/Data/OrderRepository.cs +++ /dev/null @@ -1,190 +0,0 @@ -using System.Text; -using Dapper; -using OrderMonitor.Core.Interfaces; -using OrderMonitor.Core.Models; - -namespace OrderMonitor.Infrastructure.Data; - -/// -/// Repository implementation for order data access using Dapper. -/// Queries ConsolidationOrder and OrderProductTracking tables. -/// -public class OrderRepository : IOrderRepository -{ - private readonly IDbConnectionFactory _connectionFactory; - - public OrderRepository(IDbConnectionFactory connectionFactory) - { - _connectionFactory = connectionFactory ?? throw new ArgumentNullException(nameof(connectionFactory)); - } - - /// - public async Task> GetStuckOrdersAsync( - StuckOrderQueryParams queryParams, - CancellationToken cancellationToken = default) - { - const string baseSql = @" - WITH StuckOrders AS ( - SELECT - co.CONumber AS OrderId, - co.orderNumber AS OrderNumber, - opt.Status AS StatusId, - st.Tracking_Status_Name AS Status, - mt.MajorProductTypeName AS ProductType, - opt.lastUpdatedDate AS StuckSince, - DATEDIFF(HOUR, opt.lastUpdatedDate, GETUTCDATE()) AS HoursStuck, - CASE - WHEN opt.Status BETWEEN 3001 AND 3910 THEN 6 - WHEN opt.Status BETWEEN 4001 AND 5830 THEN 48 - ELSE 24 - END AS ThresholdHours, - co.websiteCode AS Region, - NULL AS CustomerEmail, - CAST(ISNULL(opt.TPartnerCode, '') AS VARCHAR(20)) AS FacilityCode, - ISNULL(pm.PartnerDisplayName, 'Unknown') AS FacilityName, - ROW_NUMBER() OVER (PARTITION BY co.CONumber ORDER BY opt.lastUpdatedDate DESC) AS RowNum - FROM ConsolidationOrder co (NOLOCK) - INNER JOIN OrderProductTracking opt (NOLOCK) - ON opt.CONumber = co.CONumber - INNER JOIN luk_Tracking_Status st (NOLOCK) - ON st.Tracking_Status_id = opt.Status - INNER JOIN mas_SnSpecification sn (NOLOCK) - ON sn.SnID = opt.OPT_SnSpId - INNER JOIN luk_MajorProductType mt (NOLOCK) - ON mt.MProductTypeID = sn.MasterProductTypeID - LEFT JOIN Partner_Master pm (NOLOCK) - ON pm.PartnerID = opt.TPartnerCode AND pm.IsActive = 1 - WHERE opt.isPrimaryComponent = 1 - AND opt.OrderDate > DATEADD(YEAR, -2, GETUTCDATE()) - AND opt.Status < 6400 - AND ( - (opt.Status BETWEEN 3001 AND 3910 - AND DATEDIFF(HOUR, opt.lastUpdatedDate, GETUTCDATE()) > 6) - OR - (opt.Status BETWEEN 4001 AND 5830 - AND DATEDIFF(HOUR, opt.lastUpdatedDate, GETUTCDATE()) > 48) - ) - ) - SELECT OrderId, OrderNumber, StatusId, Status, ProductType, StuckSince, - HoursStuck, ThresholdHours, Region, CustomerEmail, FacilityCode, FacilityName - FROM StuckOrders - WHERE RowNum = 1"; - - var sqlBuilder = new StringBuilder(baseSql); - var parameters = new DynamicParameters(); - - // Apply optional filters (reference CTE columns, not original table aliases) - if (queryParams.StatusId.HasValue) - { - sqlBuilder.Append(" AND StatusId = @StatusId"); - parameters.Add("StatusId", queryParams.StatusId.Value); - } - - if (!string.IsNullOrWhiteSpace(queryParams.Status)) - { - sqlBuilder.Append(" AND Status LIKE @Status"); - parameters.Add("Status", $"%{queryParams.Status}%"); - } - - if (queryParams.MinHours.HasValue) - { - sqlBuilder.Append(" AND HoursStuck >= @MinHours"); - parameters.Add("MinHours", queryParams.MinHours.Value); - } - - if (queryParams.MaxHours.HasValue) - { - sqlBuilder.Append(" AND HoursStuck <= @MaxHours"); - parameters.Add("MaxHours", queryParams.MaxHours.Value); - } - - // Order by hours stuck descending (oldest first) - sqlBuilder.Append(" ORDER BY HoursStuck DESC"); - - // Apply pagination - sqlBuilder.Append(" OFFSET @Offset ROWS FETCH NEXT @Limit ROWS ONLY"); - parameters.Add("Offset", queryParams.Offset); - parameters.Add("Limit", queryParams.Limit); - - using var connection = _connectionFactory.CreateConnection(); - return await connection.QueryAsync( - new CommandDefinition(sqlBuilder.ToString(), parameters, cancellationToken: cancellationToken)); - } - - /// - public async Task GetStuckOrdersCountAsync(CancellationToken cancellationToken = default) - { - const string sql = @" - SELECT COUNT(DISTINCT co.CONumber) - FROM ConsolidationOrder co (NOLOCK) - INNER JOIN OrderProductTracking opt (NOLOCK) - ON opt.CONumber = co.CONumber - WHERE opt.isPrimaryComponent = 1 - AND opt.OrderDate > DATEADD(YEAR, -2, GETUTCDATE()) - AND opt.Status < 6400 - AND ( - (opt.Status BETWEEN 3001 AND 3910 - AND DATEDIFF(HOUR, opt.lastUpdatedDate, GETUTCDATE()) > 6) - OR - (opt.Status BETWEEN 4001 AND 5830 - AND DATEDIFF(HOUR, opt.lastUpdatedDate, GETUTCDATE()) > 48) - )"; - - using var connection = _connectionFactory.CreateConnection(); - return await connection.ExecuteScalarAsync( - new CommandDefinition(sql, cancellationToken: cancellationToken)); - } - - /// - public async Task> GetOrderStatusHistoryAsync( - string orderId, - CancellationToken cancellationToken = default) - { - const string sql = @" - WITH StatusDurations AS ( - SELECT - opt.Status AS StatusId, - st.Tracking_Status_Name AS Status, - opt.lastUpdatedDate AS Timestamp, - LEAD(opt.lastUpdatedDate) OVER (ORDER BY opt.lastUpdatedDate) AS NextTimestamp - FROM OrderProductTracking opt (NOLOCK) - INNER JOIN luk_Tracking_Status st (NOLOCK) - ON st.Tracking_Status_id = opt.Status - WHERE opt.CONumber = @OrderId - AND opt.isPrimaryComponent = 1 - ) - SELECT - StatusId, - Status, - Timestamp, - CASE - WHEN NextTimestamp IS NULL THEN - CASE - WHEN (StatusId BETWEEN 3001 AND 3910 AND DATEDIFF(HOUR, Timestamp, GETUTCDATE()) > 6) - OR (StatusId BETWEEN 4001 AND 5830 AND DATEDIFF(HOUR, Timestamp, GETUTCDATE()) > 48) - THEN CONCAT(DATEDIFF(HOUR, Timestamp, GETUTCDATE()), 'h+ (STUCK)') - ELSE CONCAT(DATEDIFF(HOUR, Timestamp, GETUTCDATE()), 'h (Current)') - END - ELSE - CASE - WHEN DATEDIFF(MINUTE, Timestamp, NextTimestamp) < 60 - THEN CONCAT(DATEDIFF(MINUTE, Timestamp, NextTimestamp), 'm') - ELSE CONCAT(DATEDIFF(HOUR, Timestamp, NextTimestamp), 'h ', - DATEDIFF(MINUTE, Timestamp, NextTimestamp) % 60, 'm') - END - END AS Duration, - CASE - WHEN NextTimestamp IS NULL AND - ((StatusId BETWEEN 3001 AND 3910 AND DATEDIFF(HOUR, Timestamp, GETUTCDATE()) > 6) - OR (StatusId BETWEEN 4001 AND 5830 AND DATEDIFF(HOUR, Timestamp, GETUTCDATE()) > 48)) - THEN 1 - ELSE 0 - END AS IsStuck - FROM StatusDurations - ORDER BY Timestamp ASC"; - - using var connection = _connectionFactory.CreateConnection(); - return await connection.QueryAsync( - new CommandDefinition(sql, new { OrderId = orderId }, cancellationToken: cancellationToken)); - } -} diff --git a/src/OrderMonitor.Infrastructure/Data/SqlConnectionFactory.cs b/src/OrderMonitor.Infrastructure/Data/SqlConnectionFactory.cs deleted file mode 100644 index 7d0a0be..0000000 --- a/src/OrderMonitor.Infrastructure/Data/SqlConnectionFactory.cs +++ /dev/null @@ -1,27 +0,0 @@ -using System.Data; -using Microsoft.Data.SqlClient; -using OrderMonitor.Core.Interfaces; - -namespace OrderMonitor.Infrastructure.Data; - -/// -/// SQL Server connection factory implementation. -/// -public class SqlConnectionFactory : IDbConnectionFactory -{ - private readonly string _connectionString; - - public SqlConnectionFactory(string connectionString) - { - if (string.IsNullOrWhiteSpace(connectionString)) - throw new ArgumentException("Connection string cannot be null or empty.", nameof(connectionString)); - - _connectionString = connectionString; - } - - /// - public IDbConnection CreateConnection() - { - return new SqlConnection(_connectionString); - } -} diff --git a/src/OrderMonitor.Infrastructure/DependencyInjection.cs b/src/OrderMonitor.Infrastructure/DependencyInjection.cs index cd5261c..fb23f99 100644 --- a/src/OrderMonitor.Infrastructure/DependencyInjection.cs +++ b/src/OrderMonitor.Infrastructure/DependencyInjection.cs @@ -21,29 +21,47 @@ public static IServiceCollection AddInfrastructure( this IServiceCollection services, IConfiguration configuration) { - // Register database connection factory - var connectionString = configuration.GetConnectionString("BackofficeDb") - ?? throw new InvalidOperationException("BackofficeDb connection string is not configured."); - - // Check if password is encrypted (contains placeholder) - if (connectionString.Contains("{ENCRYPTED}")) + // Register database context with provider factory + services.AddDbContext((sp, options) => { - var encryptedPassword = configuration["DatabaseSettings:EncryptedPassword"]; - if (!string.IsNullOrEmpty(encryptedPassword)) + var config = sp.GetRequiredService(); + + // Resolve database provider + var providerString = config["Database:Provider"] ?? "SqlServer"; + var provider = DatabaseProviderFactory.ParseProvider(providerString); + + // Resolve connection string + var connectionString = config["Database:ConnectionString"]; + if (string.IsNullOrWhiteSpace(connectionString)) + { + connectionString = config.GetConnectionString("BackofficeDb") + ?? throw new InvalidOperationException( + "Database connection string is not configured. " + + "Set Database__ConnectionString or ConnectionStrings__BackofficeDb."); + } + + // Handle encrypted passwords + if (connectionString.Contains("{ENCRYPTED}")) { - var decryptedPassword = PasswordEncryptor.Decrypt(encryptedPassword); - connectionString = connectionString.Replace("{ENCRYPTED}", decryptedPassword); + var encryptedPassword = config["DatabaseSettings:EncryptedPassword"]; + if (!string.IsNullOrEmpty(encryptedPassword)) + { + var decryptedPassword = PasswordEncryptor.Decrypt(encryptedPassword); + connectionString = connectionString.Replace("{ENCRYPTED}", decryptedPassword); + } } - } - services.AddSingleton(sp => new SqlConnectionFactory(connectionString)); + DatabaseProviderFactory.ConfigureProvider(options, provider, connectionString); + options.UseQueryTrackingBehavior(Microsoft.EntityFrameworkCore.QueryTrackingBehavior.NoTracking); + }); // Register repositories - services.AddScoped(); + services.AddScoped(); // Register services services.AddScoped(); services.AddScoped(); + services.AddScoped(); // Register configuration settings services.Configure(configuration.GetSection(ScannerSettings.SectionName)); diff --git a/src/OrderMonitor.Infrastructure/OrderMonitor.Infrastructure.csproj b/src/OrderMonitor.Infrastructure/OrderMonitor.Infrastructure.csproj index ed9fc01..3f28731 100644 --- a/src/OrderMonitor.Infrastructure/OrderMonitor.Infrastructure.csproj +++ b/src/OrderMonitor.Infrastructure/OrderMonitor.Infrastructure.csproj @@ -5,9 +5,11 @@ - - + + + + diff --git a/tests/OrderMonitor.IntegrationTests/CustomWebApplicationFactory.cs b/tests/OrderMonitor.IntegrationTests/CustomWebApplicationFactory.cs index ff51cb7..90398e9 100644 --- a/tests/OrderMonitor.IntegrationTests/CustomWebApplicationFactory.cs +++ b/tests/OrderMonitor.IntegrationTests/CustomWebApplicationFactory.cs @@ -1,10 +1,13 @@ using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Moq; using OrderMonitor.Core.Interfaces; using OrderMonitor.Core.Models; +using OrderMonitor.Infrastructure.Data; namespace OrderMonitor.IntegrationTests; @@ -16,20 +19,41 @@ public class CustomWebApplicationFactory : WebApplicationFactory { public Mock MockStuckOrderService { get; } = new(); public Mock MockAlertService { get; } = new(); + public Mock MockDiagnosticsService { get; } = new(); protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Testing"); + builder.ConfigureAppConfiguration((context, config) => + { + config.AddInMemoryCollection(new Dictionary + { + ["Database:Provider"] = "SqlServer", + ["Database:ConnectionString"] = "Server=localhost;Database=TestDb;Trusted_Connection=true;", + ["SmtpSettings:Host"] = "localhost", + ["SmtpSettings:Port"] = "25", + ["Alerts:Enabled"] = "false" + }); + }); + builder.ConfigureServices(services => { + // Replace DbContext with InMemory provider for testing + services.RemoveAll>(); + services.RemoveAll(); + services.AddDbContext(options => + options.UseInMemoryDatabase("TestDb")); + // Remove real service registrations services.RemoveAll(); services.RemoveAll(); + services.RemoveAll(); // Add mocked services services.AddSingleton(MockStuckOrderService.Object); services.AddSingleton(MockAlertService.Object); + services.AddSingleton(MockDiagnosticsService.Object); }); } @@ -42,6 +66,7 @@ public void SetupDefaultMocks() // Reset mocks for test isolation MockStuckOrderService.Reset(); MockAlertService.Reset(); + MockDiagnosticsService.Reset(); // Default stuck orders response MockStuckOrderService @@ -99,6 +124,11 @@ public void SetupDefaultMocks() } }); + // Default diagnostics mock + MockDiagnosticsService + .Setup(s => s.GetTablesAsync(It.IsAny(), It.IsAny())) + .ReturnsAsync(new[] { "ConsolidationOrder", "OrderProductTracking" }); + // Default test alert - succeeds MockAlertService .Setup(s => s.SendTestAlertAsync(It.IsAny(), It.IsAny())) diff --git a/tests/OrderMonitor.IntegrationTests/OrderMonitor.IntegrationTests.csproj b/tests/OrderMonitor.IntegrationTests/OrderMonitor.IntegrationTests.csproj index 2d39f6e..d505e03 100644 --- a/tests/OrderMonitor.IntegrationTests/OrderMonitor.IntegrationTests.csproj +++ b/tests/OrderMonitor.IntegrationTests/OrderMonitor.IntegrationTests.csproj @@ -13,7 +13,12 @@ + + + + + diff --git a/tests/OrderMonitor.IntegrationTests/Providers/DatabaseProviderTestBase.cs b/tests/OrderMonitor.IntegrationTests/Providers/DatabaseProviderTestBase.cs new file mode 100644 index 0000000..8e193cc --- /dev/null +++ b/tests/OrderMonitor.IntegrationTests/Providers/DatabaseProviderTestBase.cs @@ -0,0 +1,244 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using OrderMonitor.Core.Configuration; +using OrderMonitor.Core.Models; +using OrderMonitor.Infrastructure.Data; +using OrderMonitor.Infrastructure.Data.Entities; + +namespace OrderMonitor.IntegrationTests.Providers; + +/// +/// Shared test logic for validating EfCoreOrderRepository against real database providers. +/// Each provider test class inherits this and provides its own DbContext configuration. +/// +public abstract class DatabaseProviderTestBase : IAsyncLifetime +{ + protected OrderMonitorDbContext DbContext { get; private set; } = null!; + protected EfCoreOrderRepository Repository { get; private set; } = null!; + + protected abstract Task> CreateDbContextOptionsAsync(); + + public async Task InitializeAsync() + { + var options = await CreateDbContextOptionsAsync(); + DbContext = new OrderMonitorDbContext(options); + + // Create tables from EF model + await DbContext.Database.EnsureCreatedAsync(); + + // Seed test data + await SeedTestDataAsync(); + + Repository = new EfCoreOrderRepository(DbContext); + } + + public async Task DisposeAsync() + { + await DbContext.Database.EnsureDeletedAsync(); + await DbContext.DisposeAsync(); + await OnDisposeAsync(); + } + + protected virtual Task OnDisposeAsync() => Task.CompletedTask; + + private async Task SeedTestDataAsync() + { + // Tracking statuses + DbContext.TrackingStatuses.AddRange( + new TrackingStatusEntity { TrackingStatusId = 3050, TrackingStatusName = "PreparationStarted" }, + new TrackingStatusEntity { TrackingStatusId = 3060, TrackingStatusName = "PreparationDone" }, + new TrackingStatusEntity { TrackingStatusId = 4001, TrackingStatusName = "SentToFacility" }, + new TrackingStatusEntity { TrackingStatusId = 4200, TrackingStatusName = "PrintedInFacility" }, + new TrackingStatusEntity { TrackingStatusId = 6400, TrackingStatusName = "Completed" } + ); + + // Product types + DbContext.MajorProductTypes.AddRange( + new MajorProductTypeEntity { MProductTypeId = 1, MajorProductTypeName = "Photo Book" }, + new MajorProductTypeEntity { MProductTypeId = 2, MajorProductTypeName = "Calendar" } + ); + + // Specifications + DbContext.SnSpecifications.AddRange( + new SnSpecificationEntity { SnId = 100, MasterProductTypeId = 1 }, + new SnSpecificationEntity { SnId = 200, MasterProductTypeId = 2 } + ); + + // Partners + DbContext.Partners.AddRange( + new PartnerEntity { PartnerId = 10, PartnerDisplayName = "Facility Alpha", IsActive = true }, + new PartnerEntity { PartnerId = 20, PartnerDisplayName = "Facility Beta", IsActive = false } + ); + + // Orders + DbContext.ConsolidationOrders.AddRange( + new ConsolidationOrderEntity { CONumber = "CO001", OrderNumber = "ORD001", WebsiteCode = "US" }, + new ConsolidationOrderEntity { CONumber = "CO002", OrderNumber = "ORD002", WebsiteCode = "UK" }, + new ConsolidationOrderEntity { CONumber = "CO003", OrderNumber = "ORD003", WebsiteCode = "DE" }, + new ConsolidationOrderEntity { CONumber = "CO004", OrderNumber = "ORD004", WebsiteCode = "US" } + ); + + // Stuck prep order (>6h at status 3050) + DbContext.OrderProductTrackings.Add(new OrderProductTrackingEntity + { + Id = 1, CONumber = "CO001", Status = 3050, + LastUpdatedDate = DateTime.UtcNow.AddHours(-12), + IsPrimaryComponent = true, OptSnSpId = 100, TPartnerCode = 10, + OrderDate = DateTime.UtcNow.AddDays(-1) + }); + + // Stuck facility order (>48h at status 4001) + DbContext.OrderProductTrackings.Add(new OrderProductTrackingEntity + { + Id = 2, CONumber = "CO002", Status = 4001, + LastUpdatedDate = DateTime.UtcNow.AddHours(-72), + IsPrimaryComponent = true, OptSnSpId = 200, TPartnerCode = 10, + OrderDate = DateTime.UtcNow.AddDays(-5) + }); + + // Not stuck (status 3060, only 2h) + DbContext.OrderProductTrackings.Add(new OrderProductTrackingEntity + { + Id = 3, CONumber = "CO003", Status = 3060, + LastUpdatedDate = DateTime.UtcNow.AddHours(-2), + IsPrimaryComponent = true, OptSnSpId = 100, TPartnerCode = 10, + OrderDate = DateTime.UtcNow.AddDays(-1) + }); + + // Completed order (should be excluded) + DbContext.OrderProductTrackings.Add(new OrderProductTrackingEntity + { + Id = 4, CONumber = "CO004", Status = 6400, + LastUpdatedDate = DateTime.UtcNow.AddHours(-100), + IsPrimaryComponent = true, OptSnSpId = 100, + OrderDate = DateTime.UtcNow.AddDays(-10) + }); + + // Non-primary component (should be excluded) + DbContext.OrderProductTrackings.Add(new OrderProductTrackingEntity + { + Id = 5, CONumber = "CO001", Status = 3050, + LastUpdatedDate = DateTime.UtcNow.AddHours(-24), + IsPrimaryComponent = false, OptSnSpId = 100, + OrderDate = DateTime.UtcNow.AddDays(-1) + }); + + // Status history entry for CO001 + DbContext.OrderProductTrackings.Add(new OrderProductTrackingEntity + { + Id = 6, CONumber = "CO001", Status = 3060, + LastUpdatedDate = DateTime.UtcNow.AddHours(-20), + IsPrimaryComponent = true, OptSnSpId = 100, + OrderDate = DateTime.UtcNow.AddDays(-1) + }); + + await DbContext.SaveChangesAsync(); + } + + [Fact] + public async Task GetStuckOrders_ReturnsOnlyStuckOrders() + { + var result = await Repository.GetStuckOrdersAsync(new StuckOrderQueryParams()); + var orders = result.ToList(); + + orders.Should().HaveCount(2); + orders.Should().Contain(o => o.OrderId == "CO001"); + orders.Should().Contain(o => o.OrderId == "CO002"); + } + + [Fact] + public async Task GetStuckOrders_DeduplicatesByCONumber() + { + var result = await Repository.GetStuckOrdersAsync(new StuckOrderQueryParams()); + var orders = result.ToList(); + + orders.Count(o => o.OrderId == "CO001").Should().Be(1); + } + + [Fact] + public async Task GetStuckOrders_IncludesRelatedData() + { + var result = await Repository.GetStuckOrdersAsync(new StuckOrderQueryParams()); + var order = result.First(o => o.OrderId == "CO001"); + + order.OrderNumber.Should().Be("ORD001"); + order.Status.Should().Be("PreparationStarted"); + order.ProductType.Should().Be("Photo Book"); + order.Region.Should().Be("US"); + order.FacilityName.Should().Be("Facility Alpha"); + } + + [Fact] + public async Task GetStuckOrders_CalculatesThresholdHours() + { + var result = await Repository.GetStuckOrdersAsync(new StuckOrderQueryParams()); + var orders = result.ToList(); + + var prepOrder = orders.First(o => o.OrderId == "CO001"); + prepOrder.ThresholdHours.Should().Be(OrderStatusConfiguration.PrepThresholdHours); + + var facilityOrder = orders.First(o => o.OrderId == "CO002"); + facilityOrder.ThresholdHours.Should().Be(OrderStatusConfiguration.FacilityThresholdHours); + } + + [Fact] + public async Task GetStuckOrders_OrdersByHoursStuckDescending() + { + var result = await Repository.GetStuckOrdersAsync(new StuckOrderQueryParams()); + var orders = result.ToList(); + + orders.Should().BeInDescendingOrder(o => o.HoursStuck); + } + + [Fact] + public async Task GetStuckOrders_AppliesPagination() + { + var result = await Repository.GetStuckOrdersAsync( + new StuckOrderQueryParams { Limit = 1, Offset = 0 }); + + result.Should().HaveCount(1); + } + + [Fact] + public async Task GetStuckOrders_FiltersByStatusId() + { + var result = await Repository.GetStuckOrdersAsync( + new StuckOrderQueryParams { StatusId = 3050 }); + + result.Should().OnlyContain(o => o.StatusId == 3050); + } + + [Fact] + public async Task GetStuckOrdersCount_ReturnsDistinctCount() + { + var count = await Repository.GetStuckOrdersCountAsync(); + count.Should().Be(2); + } + + [Fact] + public async Task GetOrderStatusHistory_ReturnsOrderedTimeline() + { + var result = await Repository.GetOrderStatusHistoryAsync("CO001"); + var history = result.ToList(); + + history.Should().HaveCountGreaterThanOrEqualTo(2); + history.Should().BeInAscendingOrder(h => h.Timestamp); + } + + [Fact] + public async Task GetOrderStatusHistory_NonExistentOrder_ReturnsEmpty() + { + var result = await Repository.GetOrderStatusHistoryAsync("NONEXISTENT"); + result.Should().BeEmpty(); + } + + [Fact] + public async Task GetOrderStatusHistory_ExcludesNonPrimaryComponents() + { + var result = await Repository.GetOrderStatusHistoryAsync("CO001"); + var history = result.ToList(); + + history.Should().OnlyContain(h => + h.StatusId == 3050 || h.StatusId == 3060); + } +} diff --git a/tests/OrderMonitor.IntegrationTests/Providers/MySqlProviderTests.cs b/tests/OrderMonitor.IntegrationTests/Providers/MySqlProviderTests.cs new file mode 100644 index 0000000..fc2000d --- /dev/null +++ b/tests/OrderMonitor.IntegrationTests/Providers/MySqlProviderTests.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore; +using OrderMonitor.Infrastructure.Data; +using Testcontainers.MySql; + +namespace OrderMonitor.IntegrationTests.Providers; + +/// +/// Integration tests for EfCoreOrderRepository against a real MySQL database via Testcontainers. +/// Validates that all LINQ queries translate correctly to MySQL SQL. +/// +[Trait("Category", "Integration")] +[Trait("Provider", "MySQL")] +public class MySqlProviderTests : DatabaseProviderTestBase, IAsyncLifetime +{ + private readonly MySqlContainer _container = new MySqlBuilder("mysql:8.0") + .WithDatabase("ordermonitor_test") + .WithUsername("testuser") + .WithPassword("testpass123!") + .Build(); + + protected override async Task> CreateDbContextOptionsAsync() + { + await _container.StartAsync(); + + var connectionString = _container.GetConnectionString(); + var serverVersion = ServerVersion.AutoDetect(connectionString); + + return new DbContextOptionsBuilder() + .UseMySql(connectionString, serverVersion) + .Options; + } + + protected override async Task OnDisposeAsync() + { + await _container.DisposeAsync(); + } + + async Task IAsyncLifetime.InitializeAsync() + { + await base.InitializeAsync(); + } + + async Task IAsyncLifetime.DisposeAsync() + { + await base.DisposeAsync(); + } +} diff --git a/tests/OrderMonitor.IntegrationTests/Providers/PostgreSqlProviderTests.cs b/tests/OrderMonitor.IntegrationTests/Providers/PostgreSqlProviderTests.cs new file mode 100644 index 0000000..398e65a --- /dev/null +++ b/tests/OrderMonitor.IntegrationTests/Providers/PostgreSqlProviderTests.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore; +using OrderMonitor.Infrastructure.Data; +using Testcontainers.PostgreSql; + +namespace OrderMonitor.IntegrationTests.Providers; + +/// +/// Integration tests for EfCoreOrderRepository against a real PostgreSQL database via Testcontainers. +/// Validates that all LINQ queries translate correctly to PostgreSQL SQL. +/// +[Trait("Category", "Integration")] +[Trait("Provider", "PostgreSQL")] +public class PostgreSqlProviderTests : DatabaseProviderTestBase, IAsyncLifetime +{ + private readonly PostgreSqlContainer _container = new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("ordermonitor_test") + .WithUsername("testuser") + .WithPassword("testpass123!") + .Build(); + + protected override async Task> CreateDbContextOptionsAsync() + { + await _container.StartAsync(); + + var connectionString = _container.GetConnectionString(); + + return new DbContextOptionsBuilder() + .UseNpgsql(connectionString) + .Options; + } + + protected override async Task OnDisposeAsync() + { + await _container.DisposeAsync(); + } + + async Task IAsyncLifetime.InitializeAsync() + { + await base.InitializeAsync(); + } + + async Task IAsyncLifetime.DisposeAsync() + { + await base.DisposeAsync(); + } +} diff --git a/tests/OrderMonitor.IntegrationTests/Providers/QueryPerformanceTests.cs b/tests/OrderMonitor.IntegrationTests/Providers/QueryPerformanceTests.cs new file mode 100644 index 0000000..c3f7afa --- /dev/null +++ b/tests/OrderMonitor.IntegrationTests/Providers/QueryPerformanceTests.cs @@ -0,0 +1,197 @@ +using System.Diagnostics; +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using OrderMonitor.Core.Configuration; +using OrderMonitor.Core.Models; +using OrderMonitor.Infrastructure.Data; +using OrderMonitor.Infrastructure.Data.Entities; +using Testcontainers.PostgreSql; + +namespace OrderMonitor.IntegrationTests.Providers; + +/// +/// Performance benchmarks for EfCoreOrderRepository queries. +/// Validates that query response times stay within acceptable thresholds. +/// Uses PostgreSQL as the reference provider with realistic data volumes. +/// +[Trait("Category", "Integration")] +[Trait("Provider", "Performance")] +public class QueryPerformanceTests : IAsyncLifetime +{ + private readonly PostgreSqlContainer _container = new PostgreSqlBuilder("postgres:16-alpine") + .WithDatabase("ordermonitor_perf") + .WithUsername("perfuser") + .WithPassword("perfpass123!") + .Build(); + + private OrderMonitorDbContext _dbContext = null!; + private EfCoreOrderRepository _repository = null!; + + private const int SeedOrderCount = 500; + private const int MaxQueryTimeMs = 2000; + + public async Task InitializeAsync() + { + await _container.StartAsync(); + + var connectionString = _container.GetConnectionString(); + var options = new DbContextOptionsBuilder() + .UseNpgsql(connectionString) + .Options; + + _dbContext = new OrderMonitorDbContext(options); + await _dbContext.Database.EnsureCreatedAsync(); + await SeedPerformanceDataAsync(); + _repository = new EfCoreOrderRepository(_dbContext); + } + + public async Task DisposeAsync() + { + await _dbContext.Database.EnsureDeletedAsync(); + await _dbContext.DisposeAsync(); + await _container.DisposeAsync(); + } + + private async Task SeedPerformanceDataAsync() + { + // Seed reference data + var statuses = new[] + { + new TrackingStatusEntity { TrackingStatusId = 3001, TrackingStatusName = "Initialized" }, + new TrackingStatusEntity { TrackingStatusId = 3050, TrackingStatusName = "PreparationStarted" }, + new TrackingStatusEntity { TrackingStatusId = 3060, TrackingStatusName = "PreparationDone" }, + new TrackingStatusEntity { TrackingStatusId = 3910, TrackingStatusName = "ReadyForFacility" }, + new TrackingStatusEntity { TrackingStatusId = 4001, TrackingStatusName = "SentToFacility" }, + new TrackingStatusEntity { TrackingStatusId = 4200, TrackingStatusName = "PrintedInFacility" }, + new TrackingStatusEntity { TrackingStatusId = 5830, TrackingStatusName = "Shipped" }, + new TrackingStatusEntity { TrackingStatusId = 6400, TrackingStatusName = "Completed" } + }; + _dbContext.TrackingStatuses.AddRange(statuses); + + var productTypes = new[] + { + new MajorProductTypeEntity { MProductTypeId = 1, MajorProductTypeName = "Photo Book" }, + new MajorProductTypeEntity { MProductTypeId = 2, MajorProductTypeName = "Calendar" }, + new MajorProductTypeEntity { MProductTypeId = 3, MajorProductTypeName = "Canvas" }, + new MajorProductTypeEntity { MProductTypeId = 4, MajorProductTypeName = "Mug" } + }; + _dbContext.MajorProductTypes.AddRange(productTypes); + + var specs = new[] + { + new SnSpecificationEntity { SnId = 100, MasterProductTypeId = 1 }, + new SnSpecificationEntity { SnId = 200, MasterProductTypeId = 2 }, + new SnSpecificationEntity { SnId = 300, MasterProductTypeId = 3 }, + new SnSpecificationEntity { SnId = 400, MasterProductTypeId = 4 } + }; + _dbContext.SnSpecifications.AddRange(specs); + + _dbContext.Partners.AddRange( + new PartnerEntity { PartnerId = 10, PartnerDisplayName = "Facility Alpha", IsActive = true }, + new PartnerEntity { PartnerId = 20, PartnerDisplayName = "Facility Beta", IsActive = true }, + new PartnerEntity { PartnerId = 30, PartnerDisplayName = "Facility Gamma", IsActive = false } + ); + + await _dbContext.SaveChangesAsync(); + + // Seed orders with mixed statuses + var statusIds = new[] { 3050, 3060, 4001, 4200, 6400 }; + var specIds = new[] { 100, 200, 300, 400 }; + var partnerIds = new[] { 10, 20 }; + var websites = new[] { "US", "UK", "DE", "FR", "AU" }; + var random = new Random(42); // deterministic + + long optId = 1; + for (var i = 0; i < SeedOrderCount; i++) + { + var coNumber = $"CO{i:D5}"; + _dbContext.ConsolidationOrders.Add(new ConsolidationOrderEntity + { + CONumber = coNumber, + OrderNumber = $"ORD{i:D5}", + WebsiteCode = websites[random.Next(websites.Length)] + }); + + // Add 1-3 tracking entries per order + var entryCount = random.Next(1, 4); + for (var j = 0; j < entryCount; j++) + { + var statusId = statusIds[random.Next(statusIds.Length)]; + var hoursAgo = random.Next(1, 200); + _dbContext.OrderProductTrackings.Add(new OrderProductTrackingEntity + { + Id = optId++, + CONumber = coNumber, + Status = statusId, + LastUpdatedDate = DateTime.UtcNow.AddHours(-hoursAgo), + IsPrimaryComponent = j == 0, // first entry is primary + OptSnSpId = specIds[random.Next(specIds.Length)], + TPartnerCode = partnerIds[random.Next(partnerIds.Length)], + OrderDate = DateTime.UtcNow.AddDays(-random.Next(1, 30)) + }); + } + } + + await _dbContext.SaveChangesAsync(); + } + + [Fact] + public async Task GetStuckOrders_Performance_WithinThreshold() + { + var sw = Stopwatch.StartNew(); + var result = await _repository.GetStuckOrdersAsync(new StuckOrderQueryParams { Limit = 50 }); + sw.Stop(); + + var orders = result.ToList(); + orders.Should().NotBeEmpty("there should be stuck orders in the seeded data"); + sw.ElapsedMilliseconds.Should().BeLessThan(MaxQueryTimeMs, + $"GetStuckOrders with {SeedOrderCount} orders should complete within {MaxQueryTimeMs}ms"); + } + + [Fact] + public async Task GetStuckOrdersCount_Performance_WithinThreshold() + { + var sw = Stopwatch.StartNew(); + var count = await _repository.GetStuckOrdersCountAsync(); + sw.Stop(); + + count.Should().BeGreaterThan(0); + sw.ElapsedMilliseconds.Should().BeLessThan(MaxQueryTimeMs, + $"GetStuckOrdersCount with {SeedOrderCount} orders should complete within {MaxQueryTimeMs}ms"); + } + + [Fact] + public async Task GetOrderStatusHistory_Performance_WithinThreshold() + { + var sw = Stopwatch.StartNew(); + var result = await _repository.GetOrderStatusHistoryAsync("CO00001"); + sw.Stop(); + + sw.ElapsedMilliseconds.Should().BeLessThan(MaxQueryTimeMs, + "GetOrderStatusHistory for a single order should complete within threshold"); + } + + [Fact] + public async Task GetStuckOrders_WithFilters_Performance_WithinThreshold() + { + var sw = Stopwatch.StartNew(); + var result = await _repository.GetStuckOrdersAsync( + new StuckOrderQueryParams { StatusId = 3050, Limit = 20 }); + sw.Stop(); + + sw.ElapsedMilliseconds.Should().BeLessThan(MaxQueryTimeMs, + "Filtered GetStuckOrders should complete within threshold"); + } + + [Fact] + public async Task GetStuckOrders_Pagination_Performance_WithinThreshold() + { + var sw = Stopwatch.StartNew(); + var result = await _repository.GetStuckOrdersAsync( + new StuckOrderQueryParams { Limit = 10, Offset = 20 }); + sw.Stop(); + + sw.ElapsedMilliseconds.Should().BeLessThan(MaxQueryTimeMs, + "Paginated GetStuckOrders should complete within threshold"); + } +} diff --git a/tests/OrderMonitor.UnitTests/Data/DatabaseProviderFactoryTests.cs b/tests/OrderMonitor.UnitTests/Data/DatabaseProviderFactoryTests.cs new file mode 100644 index 0000000..caa00f2 --- /dev/null +++ b/tests/OrderMonitor.UnitTests/Data/DatabaseProviderFactoryTests.cs @@ -0,0 +1,52 @@ +using FluentAssertions; +using OrderMonitor.Infrastructure.Data; + +namespace OrderMonitor.UnitTests.Data; + +public class DatabaseProviderFactoryTests +{ + [Theory] + [InlineData("SqlServer", DatabaseProvider.SqlServer)] + [InlineData("sqlserver", DatabaseProvider.SqlServer)] + [InlineData("SQLSERVER", DatabaseProvider.SqlServer)] + [InlineData("MySql", DatabaseProvider.MySql)] + [InlineData("mysql", DatabaseProvider.MySql)] + [InlineData("PostgreSql", DatabaseProvider.PostgreSql)] + [InlineData("postgresql", DatabaseProvider.PostgreSql)] + [InlineData("postgres", DatabaseProvider.PostgreSql)] + public void ParseProvider_ValidProvider_ReturnsCorrectEnum(string input, DatabaseProvider expected) + { + var result = DatabaseProviderFactory.ParseProvider(input); + result.Should().Be(expected); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + [InlineData(null)] + public void ParseProvider_NullOrEmpty_ThrowsArgumentException(string? input) + { + var act = () => DatabaseProviderFactory.ParseProvider(input!); + act.Should().Throw() + .WithParameterName("provider"); + } + + [Theory] + [InlineData("Oracle")] + [InlineData("SQLite")] + [InlineData("MongoDB")] + public void ParseProvider_InvalidProvider_ThrowsWithAllowedValues(string input) + { + var act = () => DatabaseProviderFactory.ParseProvider(input); + act.Should().Throw() + .WithParameterName("provider") + .WithMessage($"*'{input}'*Allowed values*"); + } + + [Fact] + public void ParseProvider_WhitespaceAroundValid_TrimsAndParses() + { + var result = DatabaseProviderFactory.ParseProvider(" SqlServer "); + result.Should().Be(DatabaseProvider.SqlServer); + } +} diff --git a/tests/OrderMonitor.UnitTests/Data/EfCoreOrderRepositoryTests.cs b/tests/OrderMonitor.UnitTests/Data/EfCoreOrderRepositoryTests.cs new file mode 100644 index 0000000..b258f09 --- /dev/null +++ b/tests/OrderMonitor.UnitTests/Data/EfCoreOrderRepositoryTests.cs @@ -0,0 +1,313 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using OrderMonitor.Core.Configuration; +using OrderMonitor.Core.Models; +using OrderMonitor.Infrastructure.Data; +using OrderMonitor.Infrastructure.Data.Entities; + +namespace OrderMonitor.UnitTests.Data; + +public class EfCoreOrderRepositoryTests : IDisposable +{ + private readonly OrderMonitorDbContext _dbContext; + private readonly EfCoreOrderRepository _repository; + + public EfCoreOrderRepositoryTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + _dbContext = new OrderMonitorDbContext(options); + _repository = new EfCoreOrderRepository(_dbContext); + + SeedTestData(); + } + + private void SeedTestData() + { + // Tracking statuses + _dbContext.TrackingStatuses.AddRange( + new TrackingStatusEntity { TrackingStatusId = 3050, TrackingStatusName = "PreparationStarted" }, + new TrackingStatusEntity { TrackingStatusId = 3060, TrackingStatusName = "PreparationDone" }, + new TrackingStatusEntity { TrackingStatusId = 4001, TrackingStatusName = "SentToFacility" }, + new TrackingStatusEntity { TrackingStatusId = 4200, TrackingStatusName = "PrintedInFacility" }, + new TrackingStatusEntity { TrackingStatusId = 6400, TrackingStatusName = "Completed" } + ); + + // Product types + _dbContext.MajorProductTypes.AddRange( + new MajorProductTypeEntity { MProductTypeId = 1, MajorProductTypeName = "Photo Book" }, + new MajorProductTypeEntity { MProductTypeId = 2, MajorProductTypeName = "Calendar" } + ); + + // Specifications + _dbContext.SnSpecifications.AddRange( + new SnSpecificationEntity { SnId = 100, MasterProductTypeId = 1 }, + new SnSpecificationEntity { SnId = 200, MasterProductTypeId = 2 } + ); + + // Partners + _dbContext.Partners.AddRange( + new PartnerEntity { PartnerId = 10, PartnerDisplayName = "Facility Alpha", IsActive = true }, + new PartnerEntity { PartnerId = 20, PartnerDisplayName = "Facility Beta", IsActive = false } + ); + + // Orders + _dbContext.ConsolidationOrders.AddRange( + new ConsolidationOrderEntity { CONumber = "CO001", OrderNumber = "ORD001", WebsiteCode = "US" }, + new ConsolidationOrderEntity { CONumber = "CO002", OrderNumber = "ORD002", WebsiteCode = "UK" }, + new ConsolidationOrderEntity { CONumber = "CO003", OrderNumber = "ORD003", WebsiteCode = "DE" }, + new ConsolidationOrderEntity { CONumber = "CO004", OrderNumber = "ORD004", WebsiteCode = "US" } + ); + + // Order product trackings - stuck prep order (>6h at status 3050) + _dbContext.OrderProductTrackings.Add(new OrderProductTrackingEntity + { + Id = 1, CONumber = "CO001", Status = 3050, + LastUpdatedDate = DateTime.UtcNow.AddHours(-12), + IsPrimaryComponent = true, OptSnSpId = 100, TPartnerCode = 10, + OrderDate = DateTime.UtcNow.AddDays(-1) + }); + + // Stuck facility order (>48h at status 4001) + _dbContext.OrderProductTrackings.Add(new OrderProductTrackingEntity + { + Id = 2, CONumber = "CO002", Status = 4001, + LastUpdatedDate = DateTime.UtcNow.AddHours(-72), + IsPrimaryComponent = true, OptSnSpId = 200, TPartnerCode = 10, + OrderDate = DateTime.UtcNow.AddDays(-5) + }); + + // Not stuck (status 3060, only 2h) + _dbContext.OrderProductTrackings.Add(new OrderProductTrackingEntity + { + Id = 3, CONumber = "CO003", Status = 3060, + LastUpdatedDate = DateTime.UtcNow.AddHours(-2), + IsPrimaryComponent = true, OptSnSpId = 100, TPartnerCode = 10, + OrderDate = DateTime.UtcNow.AddDays(-1) + }); + + // Completed order (status >= 6400, should be excluded) + _dbContext.OrderProductTrackings.Add(new OrderProductTrackingEntity + { + Id = 4, CONumber = "CO004", Status = 6400, + LastUpdatedDate = DateTime.UtcNow.AddHours(-100), + IsPrimaryComponent = true, OptSnSpId = 100, + OrderDate = DateTime.UtcNow.AddDays(-10) + }); + + // Non-primary component (should be excluded) + _dbContext.OrderProductTrackings.Add(new OrderProductTrackingEntity + { + Id = 5, CONumber = "CO001", Status = 3050, + LastUpdatedDate = DateTime.UtcNow.AddHours(-24), + IsPrimaryComponent = false, OptSnSpId = 100, + OrderDate = DateTime.UtcNow.AddDays(-1) + }); + + // Status history entries for CO001 + _dbContext.OrderProductTrackings.Add(new OrderProductTrackingEntity + { + Id = 6, CONumber = "CO001", Status = 3060, + LastUpdatedDate = DateTime.UtcNow.AddHours(-20), + IsPrimaryComponent = true, OptSnSpId = 100, + OrderDate = DateTime.UtcNow.AddDays(-1) + }); + + _dbContext.SaveChanges(); + } + + [Fact] + public void Constructor_WithNullDbContext_ThrowsArgumentNullException() + { + var act = () => new EfCoreOrderRepository(null!); + act.Should().Throw() + .WithParameterName("dbContext"); + } + + [Fact] + public async Task GetStuckOrdersAsync_ReturnsOnlyStuckOrders() + { + var result = await _repository.GetStuckOrdersAsync(new StuckOrderQueryParams()); + var orders = result.ToList(); + + // CO001 is stuck (prep status, 12h > 6h threshold) + // CO002 is stuck (facility status, 72h > 48h threshold) + // CO003 is NOT stuck (2h < 6h threshold) + // CO004 is excluded (status >= 6400) + orders.Should().HaveCount(2); + orders.Should().Contain(o => o.OrderId == "CO001"); + orders.Should().Contain(o => o.OrderId == "CO002"); + } + + [Fact] + public async Task GetStuckOrdersAsync_DeduplicatesByCONumber() + { + // CO001 has multiple primary component entries (Id 1 and 6) + // Should only return one per CONumber + var result = await _repository.GetStuckOrdersAsync(new StuckOrderQueryParams()); + var orders = result.ToList(); + + orders.Count(o => o.OrderId == "CO001").Should().Be(1); + } + + [Fact] + public async Task GetStuckOrdersAsync_IncludesRelatedData() + { + var result = await _repository.GetStuckOrdersAsync(new StuckOrderQueryParams()); + var order = result.First(o => o.OrderId == "CO001"); + + order.OrderNumber.Should().Be("ORD001"); + order.Status.Should().Be("PreparationStarted"); + order.ProductType.Should().Be("Photo Book"); + order.Region.Should().Be("US"); + order.FacilityName.Should().Be("Facility Alpha"); + } + + [Fact] + public async Task GetStuckOrdersAsync_CalculatesThresholdHours() + { + var result = await _repository.GetStuckOrdersAsync(new StuckOrderQueryParams()); + var orders = result.ToList(); + + var prepOrder = orders.First(o => o.OrderId == "CO001"); + prepOrder.ThresholdHours.Should().Be(OrderStatusConfiguration.PrepThresholdHours); + + var facilityOrder = orders.First(o => o.OrderId == "CO002"); + facilityOrder.ThresholdHours.Should().Be(OrderStatusConfiguration.FacilityThresholdHours); + } + + [Fact] + public async Task GetStuckOrdersAsync_OrdersByHoursStuckDescending() + { + var result = await _repository.GetStuckOrdersAsync(new StuckOrderQueryParams()); + var orders = result.ToList(); + + orders.Should().BeInDescendingOrder(o => o.HoursStuck); + } + + [Fact] + public async Task GetStuckOrdersAsync_AppliesPagination() + { + var result = await _repository.GetStuckOrdersAsync( + new StuckOrderQueryParams { Limit = 1, Offset = 0 }); + + result.Should().HaveCount(1); + } + + [Fact] + public async Task GetStuckOrdersAsync_FiltersInactivePartners() + { + // Partner 20 is inactive, so should show "Unknown" for facility name + var result = await _repository.GetStuckOrdersAsync(new StuckOrderQueryParams()); + var facilityOrder = result.First(o => o.OrderId == "CO002"); + + // CO002 has TPartnerCode = 10 (active), so should show partner name + facilityOrder.FacilityName.Should().Be("Facility Alpha"); + } + + [Fact] + public async Task GetStuckOrdersAsync_FiltersbyStatusId() + { + var result = await _repository.GetStuckOrdersAsync( + new StuckOrderQueryParams { StatusId = 3050 }); + + result.Should().OnlyContain(o => o.StatusId == 3050); + } + + [Fact] + public async Task GetStuckOrdersAsync_FiltersByStatusName() + { + var result = await _repository.GetStuckOrdersAsync( + new StuckOrderQueryParams { Status = "Facility" }); + + result.Should().OnlyContain(o => o.Status.Contains("Facility", StringComparison.OrdinalIgnoreCase)); + } + + [Fact] + public async Task GetStuckOrdersAsync_FiltersByMinHours() + { + var result = await _repository.GetStuckOrdersAsync( + new StuckOrderQueryParams { MinHours = 50 }); + + result.Should().OnlyContain(o => o.HoursStuck >= 50); + } + + [Fact] + public async Task GetStuckOrdersCountAsync_ReturnsDistinctOrderCount() + { + var count = await _repository.GetStuckOrdersCountAsync(); + + // CO001 and CO002 are stuck + count.Should().Be(2); + } + + [Fact] + public async Task GetOrderStatusHistoryAsync_ReturnsOrderedTimeline() + { + var result = await _repository.GetOrderStatusHistoryAsync("CO001"); + var history = result.ToList(); + + // CO001 has entries at Id 1 (status 3050, -12h), Id 5 (non-primary, excluded), Id 6 (status 3060, -20h) + // Ordered by timestamp ASC: Id 6 (-20h) then Id 1 (-12h) + history.Should().HaveCountGreaterThanOrEqualTo(2); + history.Should().BeInAscendingOrder(h => h.Timestamp); + } + + [Fact] + public async Task GetOrderStatusHistoryAsync_CalculatesDurationBetweenStatuses() + { + var result = await _repository.GetOrderStatusHistoryAsync("CO001"); + var history = result.ToList(); + + // First entry should have duration to next entry + history.First().Duration.Should().NotBeNullOrEmpty(); + // Last entry should indicate current status + history.Last().Duration.Should().Contain("h"); + } + + [Fact] + public async Task GetOrderStatusHistoryAsync_IdentifiesStuckStatus() + { + var result = await _repository.GetOrderStatusHistoryAsync("CO001"); + var history = result.ToList(); + + // The last status should be stuck if it exceeds threshold + var lastEntry = history.Last(); + if (lastEntry.StatusId >= OrderStatusConfiguration.PrepMinStatusId + && lastEntry.StatusId <= OrderStatusConfiguration.PrepMaxStatusId) + { + var hoursStuck = (int)(DateTime.UtcNow - lastEntry.Timestamp).TotalHours; + if (hoursStuck > OrderStatusConfiguration.PrepThresholdHours) + { + lastEntry.IsStuck.Should().BeTrue(); + lastEntry.Duration.Should().Contain("STUCK"); + } + } + } + + [Fact] + public async Task GetOrderStatusHistoryAsync_ExcludesNonPrimaryComponents() + { + var result = await _repository.GetOrderStatusHistoryAsync("CO001"); + var history = result.ToList(); + + // Non-primary component entry (Id 5) should not appear + // Only primary entries with unique timestamps + history.Should().OnlyContain(h => + h.StatusId == 3050 || h.StatusId == 3060); + } + + [Fact] + public async Task GetOrderStatusHistoryAsync_NonExistentOrder_ReturnsEmpty() + { + var result = await _repository.GetOrderStatusHistoryAsync("NONEXISTENT"); + result.Should().BeEmpty(); + } + + public void Dispose() + { + _dbContext.Dispose(); + } +} diff --git a/tests/OrderMonitor.UnitTests/Data/EntityModelTests.cs b/tests/OrderMonitor.UnitTests/Data/EntityModelTests.cs new file mode 100644 index 0000000..c491357 --- /dev/null +++ b/tests/OrderMonitor.UnitTests/Data/EntityModelTests.cs @@ -0,0 +1,121 @@ +using FluentAssertions; +using OrderMonitor.Infrastructure.Data.Entities; + +namespace OrderMonitor.UnitTests.Data; + +public class EntityModelTests +{ + [Fact] + public void ConsolidationOrderEntity_DefaultValues() + { + var entity = new ConsolidationOrderEntity(); + entity.CONumber.Should().Be(string.Empty); + entity.OrderNumber.Should().BeNull(); + entity.WebsiteCode.Should().BeNull(); + entity.OrderProductTrackings.Should().BeEmpty(); + } + + [Fact] + public void OrderProductTrackingEntity_DefaultValues() + { + var entity = new OrderProductTrackingEntity(); + entity.Id.Should().Be(0); + entity.CONumber.Should().Be(string.Empty); + entity.Status.Should().Be(0); + entity.LastUpdatedDate.Should().BeNull(); + entity.IsPrimaryComponent.Should().BeFalse(); + entity.TPartnerCode.Should().BeNull(); + entity.OptSnSpId.Should().BeNull(); + entity.OrderDate.Should().BeNull(); + } + + [Fact] + public void TrackingStatusEntity_DefaultValues() + { + var entity = new TrackingStatusEntity(); + entity.TrackingStatusId.Should().Be(0); + entity.TrackingStatusName.Should().BeNull(); + } + + [Fact] + public void SnSpecificationEntity_DefaultValues() + { + var entity = new SnSpecificationEntity(); + entity.SnId.Should().Be(0); + entity.MasterProductTypeId.Should().BeNull(); + entity.MajorProductType.Should().BeNull(); + } + + [Fact] + public void MajorProductTypeEntity_DefaultValues() + { + var entity = new MajorProductTypeEntity(); + entity.MProductTypeId.Should().Be(0); + entity.MajorProductTypeName.Should().BeNull(); + } + + [Fact] + public void PartnerEntity_DefaultValues() + { + var entity = new PartnerEntity(); + entity.PartnerId.Should().Be(0); + entity.PartnerDisplayName.Should().BeNull(); + entity.IsActive.Should().BeFalse(); + } + + [Fact] + public void ConsolidationOrderEntity_CanSetProperties() + { + var entity = new ConsolidationOrderEntity + { + CONumber = "CO12345", + OrderNumber = "12345", + WebsiteCode = "US" + }; + + entity.CONumber.Should().Be("CO12345"); + entity.OrderNumber.Should().Be("12345"); + entity.WebsiteCode.Should().Be("US"); + } + + [Fact] + public void OrderProductTrackingEntity_CanSetAllProperties() + { + var now = DateTime.UtcNow; + var entity = new OrderProductTrackingEntity + { + Id = 42, + CONumber = "CO99999", + Status = 3050, + LastUpdatedDate = now, + IsPrimaryComponent = true, + TPartnerCode = 10, + OptSnSpId = 100, + OrderDate = now.AddDays(-1) + }; + + entity.Id.Should().Be(42); + entity.CONumber.Should().Be("CO99999"); + entity.Status.Should().Be(3050); + entity.LastUpdatedDate.Should().Be(now); + entity.IsPrimaryComponent.Should().BeTrue(); + entity.TPartnerCode.Should().Be(10); + entity.OptSnSpId.Should().Be(100); + entity.OrderDate.Should().Be(now.AddDays(-1)); + } + + [Fact] + public void PartnerEntity_CanSetProperties() + { + var entity = new PartnerEntity + { + PartnerId = 99, + PartnerDisplayName = "Test Partner", + IsActive = true + }; + + entity.PartnerId.Should().Be(99); + entity.PartnerDisplayName.Should().Be("Test Partner"); + entity.IsActive.Should().BeTrue(); + } +} diff --git a/tests/OrderMonitor.UnitTests/Data/OrderMonitorDbContextTests.cs b/tests/OrderMonitor.UnitTests/Data/OrderMonitorDbContextTests.cs new file mode 100644 index 0000000..4246276 --- /dev/null +++ b/tests/OrderMonitor.UnitTests/Data/OrderMonitorDbContextTests.cs @@ -0,0 +1,150 @@ +using FluentAssertions; +using Microsoft.EntityFrameworkCore; +using OrderMonitor.Infrastructure.Data; +using OrderMonitor.Infrastructure.Data.Entities; + +namespace OrderMonitor.UnitTests.Data; + +public class OrderMonitorDbContextTests : IDisposable +{ + private readonly OrderMonitorDbContext _dbContext; + + public OrderMonitorDbContextTests() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString()) + .Options; + + _dbContext = new OrderMonitorDbContext(options); + } + + [Fact] + public void DbContext_CanBeCreated() + { + _dbContext.Should().NotBeNull(); + } + + [Fact] + public void DbSets_AreAvailable() + { + _dbContext.ConsolidationOrders.Should().NotBeNull(); + _dbContext.OrderProductTrackings.Should().NotBeNull(); + _dbContext.TrackingStatuses.Should().NotBeNull(); + _dbContext.SnSpecifications.Should().NotBeNull(); + _dbContext.MajorProductTypes.Should().NotBeNull(); + _dbContext.Partners.Should().NotBeNull(); + } + + [Fact] + public async Task CanInsertAndRetrieveConsolidationOrder() + { + var order = new ConsolidationOrderEntity + { + CONumber = "CO12345", + OrderNumber = "12345", + WebsiteCode = "US" + }; + + _dbContext.ConsolidationOrders.Add(order); + await _dbContext.SaveChangesAsync(); + + var retrieved = await _dbContext.ConsolidationOrders.FindAsync("CO12345"); + retrieved.Should().NotBeNull(); + retrieved!.OrderNumber.Should().Be("12345"); + retrieved.WebsiteCode.Should().Be("US"); + } + + [Fact] + public async Task CanInsertAndRetrieveTrackingStatus() + { + var status = new TrackingStatusEntity + { + TrackingStatusId = 3001, + TrackingStatusName = "Initialized_New" + }; + + _dbContext.TrackingStatuses.Add(status); + await _dbContext.SaveChangesAsync(); + + var retrieved = await _dbContext.TrackingStatuses.FindAsync(3001); + retrieved.Should().NotBeNull(); + retrieved!.TrackingStatusName.Should().Be("Initialized_New"); + } + + [Fact] + public async Task CanInsertOrderProductTracking_WithRelations() + { + // Seed related entities + _dbContext.ConsolidationOrders.Add(new ConsolidationOrderEntity + { + CONumber = "CO99999", + OrderNumber = "99999" + }); + _dbContext.TrackingStatuses.Add(new TrackingStatusEntity + { + TrackingStatusId = 3050, + TrackingStatusName = "PreparationStarted" + }); + _dbContext.MajorProductTypes.Add(new MajorProductTypeEntity + { + MProductTypeId = 1, + MajorProductTypeName = "Photo Book" + }); + _dbContext.SnSpecifications.Add(new SnSpecificationEntity + { + SnId = 100, + MasterProductTypeId = 1 + }); + await _dbContext.SaveChangesAsync(); + + // Insert tracking record + var tracking = new OrderProductTrackingEntity + { + Id = 1, + CONumber = "CO99999", + Status = 3050, + LastUpdatedDate = DateTime.UtcNow.AddHours(-10), + IsPrimaryComponent = true, + OptSnSpId = 100, + OrderDate = DateTime.UtcNow.AddDays(-1) + }; + + _dbContext.OrderProductTrackings.Add(tracking); + await _dbContext.SaveChangesAsync(); + + var retrieved = await _dbContext.OrderProductTrackings + .Include(e => e.ConsolidationOrder) + .Include(e => e.TrackingStatus) + .FirstOrDefaultAsync(e => e.Id == 1); + + retrieved.Should().NotBeNull(); + retrieved!.ConsolidationOrder.Should().NotBeNull(); + retrieved.ConsolidationOrder!.CONumber.Should().Be("CO99999"); + retrieved.TrackingStatus.Should().NotBeNull(); + retrieved.TrackingStatus!.TrackingStatusName.Should().Be("PreparationStarted"); + } + + [Fact] + public async Task CanInsertPartner() + { + var partner = new PartnerEntity + { + PartnerId = 42, + PartnerDisplayName = "Test Facility", + IsActive = true + }; + + _dbContext.Partners.Add(partner); + await _dbContext.SaveChangesAsync(); + + var retrieved = await _dbContext.Partners.FindAsync(42); + retrieved.Should().NotBeNull(); + retrieved!.PartnerDisplayName.Should().Be("Test Facility"); + retrieved.IsActive.Should().BeTrue(); + } + + public void Dispose() + { + _dbContext.Dispose(); + } +} diff --git a/tests/OrderMonitor.UnitTests/Data/OrderRepositoryTests.cs b/tests/OrderMonitor.UnitTests/Data/OrderRepositoryTests.cs deleted file mode 100644 index 0d26432..0000000 --- a/tests/OrderMonitor.UnitTests/Data/OrderRepositoryTests.cs +++ /dev/null @@ -1,32 +0,0 @@ -using FluentAssertions; -using Moq; -using OrderMonitor.Core.Interfaces; -using OrderMonitor.Core.Models; -using OrderMonitor.Infrastructure.Data; - -namespace OrderMonitor.UnitTests.Data; - -public class OrderRepositoryTests -{ - [Fact] - public void Constructor_WithNullConnectionFactory_ThrowsArgumentNullException() - { - // Act & Assert - var act = () => new OrderRepository(null!); - act.Should().Throw() - .WithParameterName("connectionFactory"); - } - - [Fact] - public void Constructor_WithValidConnectionFactory_CreatesInstance() - { - // Arrange - var connectionFactoryMock = new Mock(); - - // Act - var repository = new OrderRepository(connectionFactoryMock.Object); - - // Assert - repository.Should().NotBeNull(); - } -} diff --git a/tests/OrderMonitor.UnitTests/Data/SqlConnectionFactoryTests.cs b/tests/OrderMonitor.UnitTests/Data/SqlConnectionFactoryTests.cs deleted file mode 100644 index 080352d..0000000 --- a/tests/OrderMonitor.UnitTests/Data/SqlConnectionFactoryTests.cs +++ /dev/null @@ -1,82 +0,0 @@ -using FluentAssertions; -using Microsoft.Data.SqlClient; -using OrderMonitor.Infrastructure.Data; - -namespace OrderMonitor.UnitTests.Data; - -public class SqlConnectionFactoryTests -{ - [Fact] - public void Constructor_WithNullConnectionString_ThrowsArgumentException() - { - // Act & Assert - var act = () => new SqlConnectionFactory(null!); - act.Should().Throw() - .WithParameterName("connectionString"); - } - - [Fact] - public void Constructor_WithEmptyConnectionString_ThrowsArgumentException() - { - // Act & Assert - var act = () => new SqlConnectionFactory(string.Empty); - act.Should().Throw() - .WithParameterName("connectionString"); - } - - [Fact] - public void Constructor_WithWhitespaceConnectionString_ThrowsArgumentException() - { - // Act & Assert - var act = () => new SqlConnectionFactory(" "); - act.Should().Throw() - .WithParameterName("connectionString"); - } - - [Fact] - public void Constructor_WithValidConnectionString_CreatesInstance() - { - // Arrange - const string connectionString = "Server=localhost;Database=TestDb;"; - - // Act - var factory = new SqlConnectionFactory(connectionString); - - // Assert - factory.Should().NotBeNull(); - } - - [Fact] - public void CreateConnection_ReturnsSqlConnection() - { - // Arrange - const string connectionString = "Server=localhost;Database=TestDb;"; - var factory = new SqlConnectionFactory(connectionString); - - // Act - var connection = factory.CreateConnection(); - - // Assert - connection.Should().NotBeNull(); - connection.Should().BeOfType(); - } - - [Fact] - public void CreateConnection_ReturnsNewConnectionEachTime() - { - // Arrange - const string connectionString = "Server=localhost;Database=TestDb;"; - var factory = new SqlConnectionFactory(connectionString); - - // Act - var connection1 = factory.CreateConnection(); - var connection2 = factory.CreateConnection(); - - // Assert - connection1.Should().NotBeSameAs(connection2); - - // Cleanup - connection1.Dispose(); - connection2.Dispose(); - } -} diff --git a/tests/OrderMonitor.UnitTests/OrderMonitor.UnitTests.csproj b/tests/OrderMonitor.UnitTests/OrderMonitor.UnitTests.csproj index 0048611..d43d728 100644 --- a/tests/OrderMonitor.UnitTests/OrderMonitor.UnitTests.csproj +++ b/tests/OrderMonitor.UnitTests/OrderMonitor.UnitTests.csproj @@ -12,6 +12,7 @@ +