Files
pos-system/services/order-service-net/tests/OrderService.FunctionalTests/CustomWebApplicationFactory.cs
Ho Ngoc Hai 1d12a7980b feat: add order lifecycle integration tests (29 tests) and staging K8s deployment manifests
Testing (P0-7):
- 29 functional tests for order-service API (create/pay/complete/cancel lifecycle)
- CustomWebApplicationFactory with InMemory DB, mocked wallet/SignalR/tenant
- TestAuthHandler for JWT auth in tests
- Full lifecycle tests: cash flow and online payment flow end-to-end

Staging Deployment (P0-8):
- K8s manifests for 8 MVP services + Redis + POS web (namespace, configmap, secrets)
- Traefik Ingress with path-based routing and TLS via cert-manager
- HPA auto-scaling (2-4 replicas, CPU/memory thresholds)
- deploy-staging.sh script with --dry-run and --service flags
- CI/CD: deploy-staging.yml and docker-build.yml with matrix strategy
- Consistent patterns: port 8080, 3 health probes, RollingUpdate

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-06 13:56:03 +07:00

412 lines
19 KiB
C#

// 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;
/// <summary>
/// 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.
/// </summary>
public class CustomWebApplicationFactory : WebApplicationFactory<Program>
{
/// <summary>
/// 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.
/// </summary>
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<OrderContext>) ||
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<DbContextOptions<OrderContext>>();
services.AddDbContext<OrderContext>(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<IDbConnection>();
services.AddTransient<IDbConnection>(_ => 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<IWalletServiceClient>();
services.AddSingleton<IWalletServiceClient, MockWalletServiceClient>();
// EN: Mock IPosNotificationService — no-op (skip SignalR)
// VI: Mock IPosNotificationService — no-op (bỏ qua SignalR)
services.RemoveAll<IPosNotificationService>();
services.AddSingleton<IPosNotificationService, MockPosNotificationService>();
// 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<ILineItemStrategy>();
services.AddTransient<ILineItemStrategy>(_ => new MockLineItemStrategy("Physical"));
services.AddTransient<ILineItemStrategy>(_ => new MockLineItemStrategy("Service"));
services.AddTransient<ILineItemStrategy>(_ => new MockLineItemStrategy("PreparedFood"));
// EN: Mock IOrderTenantProvider — bypass tenant filtering in tests
// VI: Mock IOrderTenantProvider — bỏ qua tenant filtering trong tests
services.RemoveAll<IOrderTenantProvider>();
services.AddSingleton<IOrderTenantProvider, MockTenantProvider>();
services.RemoveAll<ITenantProvider>();
services.AddSingleton<ITenantProvider, MockApiTenantProvider>();
// 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<AuthenticationSchemeOptions, TestAuthHandler>(
TestAuthHandler.SchemeName, _ => { });
});
}
/// <summary>
/// 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.
/// </summary>
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<OrderContext>();
db.Database.EnsureCreated();
return host;
}
}
// ============================================================
// EN: Mock implementations
// VI: Mock implementations
// ============================================================
/// <summary>
/// EN: Mock wallet service client that always succeeds.
/// VI: Mock wallet service client luôn thành công.
/// </summary>
internal class MockWalletServiceClient : IWalletServiceClient
{
public Task<CreatePaymentResponse?> CreatePaymentAsync(
Guid orderId, decimal amount, string gateway,
string returnUrl, string ipAddress,
CancellationToken cancellationToken = default)
{
return Task.FromResult<CreatePaymentResponse?>(
new CreatePaymentResponse(
TransactionId: $"MOCK-{Guid.NewGuid():N}",
PaymentUrl: $"https://mock-gateway.test/pay?txn=MOCK-{orderId}",
Status: "Pending"));
}
}
/// <summary>
/// EN: Mock POS notification service — no-op.
/// VI: Mock POS notification service — no-op.
/// </summary>
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;
}
/// <summary>
/// EN: Mock line item strategy — always validates and executes successfully.
/// VI: Mock line item strategy — luôn validate và execute thành công.
/// </summary>
internal class MockLineItemStrategy : ILineItemStrategy
{
public string SupportedType { get; }
public MockLineItemStrategy(string supportedType)
{
SupportedType = supportedType;
}
public Task<bool> ValidateAsync(OrderItem item, Guid shopId, CancellationToken cancellationToken = default)
=> Task.FromResult(true);
public Task ExecuteAsync(OrderItem item, Guid shopId, CancellationToken cancellationToken = default)
=> Task.CompletedTask;
}
/// <summary>
/// EN: Mock tenant provider for Infrastructure layer — bypasses tenant filtering.
/// VI: Mock tenant provider cho lớp Infrastructure — bỏ qua tenant filtering.
/// </summary>
internal class MockTenantProvider : IOrderTenantProvider
{
public Guid? GetCurrentShopId() => null;
public bool ShouldBypassTenantFilter() => true;
}
/// <summary>
/// EN: Mock API tenant provider — returns test tenant IDs.
/// VI: Mock API tenant provider — trả về test tenant IDs.
/// </summary>
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)
/// <summary>
/// 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.
/// </summary>
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<object> _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;
}