// EN: Custom WebApplicationFactory for functional tests with full dependency mocking. // VI: WebApplicationFactory tùy chỉnh cho functional tests với mock đầy đủ dependencies. using System.Data; using Microsoft.AspNetCore.Authentication; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.SignalR; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using OrderService.API.Hubs; using OrderService.API.Infrastructure.Tenant; using OrderService.Domain.AggregatesModel.OrderAggregate; using OrderService.Domain.Strategies; using OrderService.Infrastructure; using OrderService.Infrastructure.ExternalServices; using Serilog; namespace OrderService.FunctionalTests; /// /// EN: Custom WebApplicationFactory for functional tests. /// Replaces PostgreSQL with InMemory database, mocks external services, /// and provides test authentication. /// VI: WebApplicationFactory tùy chỉnh cho functional tests. /// Thay thế PostgreSQL bằng InMemory database, mock external services, /// và cung cấp test authentication. /// public class CustomWebApplicationFactory : WebApplicationFactory { /// /// EN: Unique database name per factory instance to isolate test data. /// VI: Tên database duy nhất cho mỗi factory instance để cách ly dữ liệu test. /// private readonly string _databaseName = $"TestDatabase_{Guid.NewGuid()}"; public CustomWebApplicationFactory() { // EN: Reset Serilog static logger before host creation to prevent "logger is already frozen". // Program.cs calls CreateBootstrapLogger() which creates a ReloadableLogger. // UseSerilog() then freezes it. On subsequent factory instances in the same // test process, Log.Logger is already frozen. Reset it to a fresh bootstrap logger. // VI: Reset Serilog static logger trước khi tạo host để ngăn "logger is already frozen". Log.Logger = new LoggerConfiguration() .MinimumLevel.Warning() .WriteTo.Console() .CreateBootstrapLogger(); } protected override void ConfigureWebHost(IWebHostBuilder builder) { builder.UseEnvironment("Testing"); builder.ConfigureServices(services => { // ============================================================ // EN: Replace DbContext with InMemory database. // Must remove ALL EF Core / Npgsql service registrations // to avoid "multiple database providers" error. // VI: Thay thế DbContext bằng InMemory database. // Phải xóa TẤT CẢ đăng ký EF Core / Npgsql services // để tránh lỗi "multiple database providers". // ============================================================ var descriptorsToRemove = services .Where(d => d.ServiceType == typeof(DbContextOptions) || d.ServiceType == typeof(OrderContext) || d.ServiceType.FullName?.Contains("Npgsql") == true || d.ServiceType.FullName?.Contains("RelationalConnection") == true || (d.ServiceType.IsGenericType && d.ServiceType.GetGenericTypeDefinition().FullName?.Contains("DbContextOptions") == true)) .ToList(); foreach (var descriptor in descriptorsToRemove) { services.Remove(descriptor); } // EN: Also remove the generic DbContextOptions registration // VI: Cũng xóa đăng ký DbContextOptions generic services.RemoveAll>(); services.AddDbContext(options => { options.UseInMemoryDatabase(_databaseName); }); // ============================================================ // EN: Replace IDbConnection (Dapper) with a no-op to avoid PostgreSQL dependency. // Queries that use Dapper with raw SQL won't work with InMemory, // so we provide a mock connection that returns empty results. // VI: Thay thế IDbConnection (Dapper) để tránh phụ thuộc PostgreSQL. // Queries dùng Dapper với raw SQL không hoạt động với InMemory, // nên cung cấp mock connection trả về kết quả rỗng. // ============================================================ services.RemoveAll(); services.AddTransient(_ => new MockDbConnection()); // ============================================================ // EN: Mock external services // VI: Mock external services // ============================================================ // EN: Mock IWalletServiceClient — return success for all payment requests // VI: Mock IWalletServiceClient — trả về thành công cho tất cả yêu cầu thanh toán services.RemoveAll(); services.AddSingleton(); // EN: Mock IPosNotificationService — no-op (skip SignalR) // VI: Mock IPosNotificationService — no-op (bỏ qua SignalR) services.RemoveAll(); services.AddSingleton(); // EN: Mock ILineItemStrategy — always return valid for all product types // VI: Mock ILineItemStrategy — luôn trả về hợp lệ cho tất cả loại sản phẩm services.RemoveAll(); services.AddTransient(_ => new MockLineItemStrategy("Physical")); services.AddTransient(_ => new MockLineItemStrategy("Service")); services.AddTransient(_ => new MockLineItemStrategy("PreparedFood")); // EN: Mock IOrderTenantProvider — bypass tenant filtering in tests // VI: Mock IOrderTenantProvider — bỏ qua tenant filtering trong tests services.RemoveAll(); services.AddSingleton(); services.RemoveAll(); services.AddSingleton(); // EN: Remove TransactionBehavior — InMemory database does not support transactions. // The ExecutionStrategy from InMemory throws NotSupportedException. // VI: Xóa TransactionBehavior — InMemory database không hỗ trợ transactions. var transactionBehaviors = services .Where(d => d.ServiceType.IsGenericType && d.ServiceType.GetGenericTypeDefinition() == typeof(MediatR.IPipelineBehavior<,>) && d.ImplementationType?.Name?.Contains("TransactionBehavior") == true) .ToList(); foreach (var descriptor in transactionBehaviors) { services.Remove(descriptor); } // EN: Note: IUserIdProvider is registered by SignalR and needed at runtime. // ClaimsUserIdProvider is already registered in Program.cs, so no action needed. // VI: IUserIdProvider đã được đăng ký bởi SignalR và cần tại runtime. // ClaimsUserIdProvider đã đăng ký trong Program.cs, không cần thao tác. // ============================================================ // EN: Replace authentication with test scheme // VI: Thay thế authentication bằng test scheme // ============================================================ services.AddAuthentication(options => { options.DefaultAuthenticateScheme = TestAuthHandler.SchemeName; options.DefaultChallengeScheme = TestAuthHandler.SchemeName; }) .AddScheme( TestAuthHandler.SchemeName, _ => { }); }); } /// /// EN: Seed database after host is created but before tests run. /// VI: Seed database sau khi host được tạo nhưng trước khi tests chạy. /// protected override IHost CreateHost(IHostBuilder builder) { var host = base.CreateHost(builder); // EN: Seed the InMemory database with OrderStatus enum data // VI: Seed InMemory database với OrderStatus enum data using var scope = host.Services.CreateScope(); var db = scope.ServiceProvider.GetRequiredService(); db.Database.EnsureCreated(); return host; } } // ============================================================ // EN: Mock implementations // VI: Mock implementations // ============================================================ /// /// EN: Mock wallet service client that always succeeds. /// VI: Mock wallet service client luôn thành công. /// internal class MockWalletServiceClient : IWalletServiceClient { public Task CreatePaymentAsync( Guid orderId, decimal amount, string gateway, string returnUrl, string ipAddress, CancellationToken cancellationToken = default) { return Task.FromResult( new CreatePaymentResponse( TransactionId: $"MOCK-{Guid.NewGuid():N}", PaymentUrl: $"https://mock-gateway.test/pay?txn=MOCK-{orderId}", Status: "Pending")); } } /// /// EN: Mock POS notification service — no-op. /// VI: Mock POS notification service — no-op. /// internal class MockPosNotificationService : IPosNotificationService { public Task NotifyOrderCreatedAsync(Guid shopId, OrderNotificationDto order, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task NotifyOrderUpdatedAsync(Guid shopId, OrderNotificationDto order, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task NotifyOrderStatusChangedAsync(Guid shopId, Guid orderId, string oldStatus, string newStatus, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task NotifyKitchenTicketCreatedAsync(Guid shopId, KitchenTicketNotificationDto ticket, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task NotifyKitchenTicketUpdatedAsync(Guid shopId, KitchenTicketNotificationDto ticket, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task NotifyPaymentCompletedAsync(Guid shopId, PaymentNotificationDto payment, CancellationToken cancellationToken = default) => Task.CompletedTask; public Task NotifyTableStatusChangedAsync(Guid shopId, TableStatusNotificationDto tableStatus, CancellationToken cancellationToken = default) => Task.CompletedTask; } /// /// EN: Mock line item strategy — always validates and executes successfully. /// VI: Mock line item strategy — luôn validate và execute thành công. /// internal class MockLineItemStrategy : ILineItemStrategy { public string SupportedType { get; } public MockLineItemStrategy(string supportedType) { SupportedType = supportedType; } public Task ValidateAsync(OrderItem item, Guid shopId, CancellationToken cancellationToken = default) => Task.FromResult(true); public Task ExecuteAsync(OrderItem item, Guid shopId, CancellationToken cancellationToken = default) => Task.CompletedTask; } /// /// EN: Mock tenant provider for Infrastructure layer — bypasses tenant filtering. /// VI: Mock tenant provider cho lớp Infrastructure — bỏ qua tenant filtering. /// internal class MockTenantProvider : IOrderTenantProvider { public Guid? GetCurrentShopId() => null; public bool ShouldBypassTenantFilter() => true; } /// /// EN: Mock API tenant provider — returns test tenant IDs. /// VI: Mock API tenant provider — trả về test tenant IDs. /// internal class MockApiTenantProvider : ITenantProvider { public Guid? GetCurrentUserId() => TestAuthHandler.TestUserId; public Guid? GetCurrentMerchantId() => TestAuthHandler.TestMerchantId; public Guid? GetCurrentShopId() => TestAuthHandler.TestShopId; public bool IsServiceCall() => false; public bool IsAdmin() => true; } #pragma warning disable CS8767 // Nullability of reference types in interface implementations (mock objects) /// /// EN: Mock IDbConnection for Dapper queries — returns empty results. /// Since Dapper queries use raw SQL with PostgreSQL-specific joins, /// they cannot work with InMemory database. This mock prevents /// connection errors while allowing command-side tests to pass. /// VI: Mock IDbConnection cho Dapper queries — trả về kết quả rỗng. /// Vì Dapper queries dùng raw SQL với PostgreSQL-specific joins, /// chúng không hoạt động với InMemory database. Mock này ngăn /// connection errors trong khi cho phép command-side tests pass. /// internal class MockDbConnection : IDbConnection { public string ConnectionString { get; set; } = string.Empty; public int ConnectionTimeout => 30; public string Database => "test"; public ConnectionState State => ConnectionState.Open; public IDbTransaction BeginTransaction() => new MockDbTransaction(this); public IDbTransaction BeginTransaction(IsolationLevel il) => new MockDbTransaction(this); public void ChangeDatabase(string databaseName) { } public void Close() { } public IDbCommand CreateCommand() => new MockDbCommand(); public void Dispose() { } public void Open() { } } internal class MockDbTransaction : IDbTransaction { public IDbConnection Connection { get; } public IsolationLevel IsolationLevel => IsolationLevel.ReadCommitted; public MockDbTransaction(IDbConnection connection) { Connection = connection; } public void Commit() { } public void Rollback() { } public void Dispose() { } } internal class MockDbCommand : IDbCommand { public string CommandText { get; set; } = string.Empty; public int CommandTimeout { get; set; } = 30; public CommandType CommandType { get; set; } = CommandType.Text; public IDbConnection? Connection { get; set; } public IDataParameterCollection Parameters => new MockParameterCollection(); public IDbTransaction? Transaction { get; set; } public UpdateRowSource UpdatedRowSource { get; set; } = UpdateRowSource.None; public void Cancel() { } public IDbDataParameter CreateParameter() => new MockDbDataParameter(); public void Dispose() { } public int ExecuteNonQuery() => 0; public IDataReader ExecuteReader() => new MockDataReader(); public IDataReader ExecuteReader(CommandBehavior behavior) => new MockDataReader(); public object? ExecuteScalar() => 0; public void Prepare() { } } internal class MockDbDataParameter : IDbDataParameter { public DbType DbType { get; set; } public ParameterDirection Direction { get; set; } public bool IsNullable => true; public string ParameterName { get; set; } = string.Empty; public string SourceColumn { get; set; } = string.Empty; public DataRowVersion SourceVersion { get; set; } public object? Value { get; set; } public byte Precision { get; set; } public byte Scale { get; set; } public int Size { get; set; } } internal class MockParameterCollection : IDataParameterCollection { private readonly List _list = new(); public object this[string parameterName] { get => null!; set { } } public object this[int index] { get => _list[index]; set => _list[index] = value; } public bool IsFixedSize => false; public bool IsReadOnly => false; public bool IsSynchronized => false; public int Count => _list.Count; public object SyncRoot => _list; public int Add(object value) { _list.Add(value); return _list.Count - 1; } public void Clear() => _list.Clear(); public bool Contains(string parameterName) => false; public bool Contains(object value) => _list.Contains(value); public void CopyTo(Array array, int index) { } public System.Collections.IEnumerator GetEnumerator() => _list.GetEnumerator(); public int IndexOf(string parameterName) => -1; public int IndexOf(object value) => _list.IndexOf(value); public void Insert(int index, object value) => _list.Insert(index, value); public void Remove(object value) => _list.Remove(value); public void RemoveAt(string parameterName) { } public void RemoveAt(int index) => _list.RemoveAt(index); } internal class MockDataReader : IDataReader { public int Depth => 0; public bool IsClosed => false; public int RecordsAffected => 0; public int FieldCount => 0; public object this[string name] => throw new IndexOutOfRangeException(); public object this[int i] => throw new IndexOutOfRangeException(); public void Close() { } public void Dispose() { } public bool GetBoolean(int i) => false; public byte GetByte(int i) => 0; public long GetBytes(int i, long fieldOffset, byte[]? buffer, int bufferoffset, int length) => 0; public char GetChar(int i) => '\0'; public long GetChars(int i, long fieldOffset, char[]? buffer, int bufferoffset, int length) => 0; public IDataReader GetData(int i) => this; public string GetDataTypeName(int i) => string.Empty; public DateTime GetDateTime(int i) => DateTime.MinValue; public decimal GetDecimal(int i) => 0; public double GetDouble(int i) => 0; public Type GetFieldType(int i) => typeof(object); public float GetFloat(int i) => 0; public Guid GetGuid(int i) => Guid.Empty; public short GetInt16(int i) => 0; public int GetInt32(int i) => 0; public long GetInt64(int i) => 0; public string GetName(int i) => string.Empty; public int GetOrdinal(string name) => -1; public DataTable GetSchemaTable() => new DataTable(); public string GetString(int i) => string.Empty; public object GetValue(int i) => DBNull.Value; public int GetValues(object[] values) => 0; public bool IsDBNull(int i) => true; public bool NextResult() => false; public bool Read() => false; }