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>
412 lines
19 KiB
C#
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;
|
|
}
|