feat(infrastructure): Enhance dependency injection for Redis caching and add InMemory cache service for testing
- Updated AddInfrastructure method to accept an environment name parameter for conditional Redis caching configuration. - Implemented logic to skip Redis caching setup in the Testing environment. - Added InMemoryCacheService for testing purposes, providing a mock implementation of ICacheService. - Enhanced TransactionBehavior to skip transactions for InMemory databases. - Updated functional tests to remove Redis-related services and ensure proper database setup for testing.
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
using MediatR;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using IamService.Infrastructure;
|
||||
|
||||
namespace IamService.API.Application.Behaviors;
|
||||
@@ -44,6 +45,13 @@ public class TransactionBehavior<TRequest, TResponse> : IPipelineBehavior<TReque
|
||||
{
|
||||
return await next();
|
||||
}
|
||||
|
||||
// EN: Skip transactions for InMemory database (not supported)
|
||||
// VI: Bỏ qua transactions cho InMemory database (không được hỗ trợ)
|
||||
if (_dbContext.Database.ProviderName?.Contains("InMemory") == true)
|
||||
{
|
||||
return await next();
|
||||
}
|
||||
|
||||
var strategy = _dbContext.Database.CreateExecutionStrategy();
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ try
|
||||
|
||||
// EN: Add Infrastructure services (Identity, OpenIddict, Repositories)
|
||||
// VI: Thêm Infrastructure services (Identity, OpenIddict, Repositories)
|
||||
builder.Services.AddInfrastructure(builder.Configuration);
|
||||
builder.Services.AddInfrastructure(builder.Configuration, builder.Environment.EnvironmentName);
|
||||
|
||||
// EN: Add MediatR with behaviors / VI: Thêm MediatR với behaviors
|
||||
builder.Services.AddMediatR(cfg =>
|
||||
|
||||
@@ -21,7 +21,8 @@ public static class DependencyInjection
|
||||
/// </summary>
|
||||
public static IServiceCollection AddInfrastructure(
|
||||
this IServiceCollection services,
|
||||
IConfiguration configuration)
|
||||
IConfiguration configuration,
|
||||
string? environmentName = null)
|
||||
{
|
||||
// EN: Get database connection string
|
||||
// VI: Lấy database connection string
|
||||
@@ -140,19 +141,23 @@ public static class DependencyInjection
|
||||
services.AddScoped<IRoleRepository, RoleRepository>();
|
||||
services.AddScoped<IUnitOfWork>(sp => sp.GetRequiredService<IamServiceContext>());
|
||||
|
||||
// EN: Configure Redis caching
|
||||
// VI: Cấu hình Redis caching
|
||||
var redisSettings = new Caching.RedisSettings();
|
||||
configuration.GetSection(Caching.RedisSettings.SectionName).Bind(redisSettings);
|
||||
|
||||
services.AddSingleton<StackExchange.Redis.IConnectionMultiplexer>(sp =>
|
||||
// EN: Configure Redis caching (skip in Testing environment)
|
||||
// VI: Cấu hình Redis caching (bỏ qua trong Testing environment)
|
||||
if (!string.Equals(environmentName, "Testing", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
var connectionString = redisSettings.GetConnectionString();
|
||||
return StackExchange.Redis.ConnectionMultiplexer.Connect(connectionString);
|
||||
});
|
||||
|
||||
services.AddSingleton<Caching.ICacheService, Caching.RedisCacheService>();
|
||||
var redisSettings = new Caching.RedisSettings();
|
||||
configuration.GetSection(Caching.RedisSettings.SectionName).Bind(redisSettings);
|
||||
|
||||
services.AddSingleton<StackExchange.Redis.IConnectionMultiplexer>(sp =>
|
||||
{
|
||||
var connectionString = redisSettings.GetConnectionString();
|
||||
return StackExchange.Redis.ConnectionMultiplexer.Connect(connectionString);
|
||||
});
|
||||
|
||||
services.AddSingleton<Caching.ICacheService, Caching.RedisCacheService>();
|
||||
}
|
||||
|
||||
return services;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,13 @@ public class AuthControllerTests : IClassFixture<CustomWebApplicationFactory>
|
||||
// Act
|
||||
var response = await _client.PostAsJsonAsync("/api/v1/auth/register", request);
|
||||
|
||||
// Debug: Print response body if not Created
|
||||
if (response.StatusCode != HttpStatusCode.Created)
|
||||
{
|
||||
var body = await response.Content.ReadAsStringAsync();
|
||||
throw new Exception($"Expected Created, got {response.StatusCode}. Body: {body}");
|
||||
}
|
||||
|
||||
// Assert
|
||||
Assert.Equal(HttpStatusCode.Created, response.StatusCode);
|
||||
|
||||
|
||||
@@ -4,6 +4,8 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.DependencyInjection.Extensions;
|
||||
using IamService.Infrastructure;
|
||||
using IamService.Infrastructure.Caching;
|
||||
using StackExchange.Redis;
|
||||
|
||||
namespace IamService.FunctionalTests;
|
||||
|
||||
@@ -19,6 +21,20 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
|
||||
|
||||
builder.ConfigureServices(services =>
|
||||
{
|
||||
// EN: Remove Redis-related registrations FIRST (before they get resolved)
|
||||
// VI: Xóa các đăng ký liên quan đến Redis TRƯỚC TIÊN (trước khi chúng được resolve)
|
||||
var redisDescriptors = services.Where(d =>
|
||||
d.ServiceType == typeof(IConnectionMultiplexer) ||
|
||||
d.ServiceType == typeof(ICacheService) ||
|
||||
d.ImplementationType?.FullName?.Contains("Redis") == true ||
|
||||
d.ServiceType.FullName?.Contains("Redis") == true)
|
||||
.ToList();
|
||||
|
||||
foreach (var descriptor in redisDescriptors)
|
||||
{
|
||||
services.Remove(descriptor);
|
||||
}
|
||||
|
||||
// EN: Remove ALL DbContext-related registrations
|
||||
// VI: Xóa TẤT CẢ các đăng ký liên quan đến DbContext
|
||||
var descriptorsToRemove = services.Where(
|
||||
@@ -37,20 +53,134 @@ public class CustomWebApplicationFactory : WebApplicationFactory<Program>
|
||||
// VI: Xóa DbContextOptions generic
|
||||
services.RemoveAll(typeof(DbContextOptions));
|
||||
|
||||
// EN: Add mock cache service for testing
|
||||
// VI: Thêm mock cache service để test
|
||||
services.AddSingleton<ICacheService, InMemoryCacheService>();
|
||||
|
||||
// EN: Add in-memory database for testing
|
||||
// VI: Thêm in-memory database để test
|
||||
var dbName = "TestDatabase_" + Guid.NewGuid().ToString();
|
||||
services.AddDbContext<IamServiceContext>(options =>
|
||||
{
|
||||
options.UseInMemoryDatabase("TestDatabase_" + Guid.NewGuid().ToString());
|
||||
options.UseInMemoryDatabase(dbName);
|
||||
options.UseOpenIddict(); // EN: Required for OpenIddict / VI: Cần cho OpenIddict
|
||||
options.EnableSensitiveDataLogging();
|
||||
});
|
||||
|
||||
// EN: Ensure database is created with seed data
|
||||
// VI: Đảm bảo database được tạo với seed data
|
||||
var sp = services.BuildServiceProvider();
|
||||
using var scope = sp.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IamServiceContext>();
|
||||
db.Database.EnsureCreated();
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: Ensure database is created after host is built
|
||||
/// VI: Đảm bảo database được tạo sau khi host được build
|
||||
/// </summary>
|
||||
protected override void ConfigureClient(HttpClient client)
|
||||
{
|
||||
base.ConfigureClient(client);
|
||||
|
||||
// EN: Create the database when the first client is created
|
||||
// VI: Tạo database khi client đầu tiên được tạo
|
||||
using var scope = Services.CreateScope();
|
||||
var db = scope.ServiceProvider.GetRequiredService<IamServiceContext>();
|
||||
db.Database.EnsureCreated();
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// EN: In-memory cache service for testing
|
||||
/// VI: Cache service in-memory cho testing
|
||||
/// </summary>
|
||||
public class InMemoryCacheService : ICacheService
|
||||
{
|
||||
private readonly Dictionary<string, (object Value, DateTime? Expiry)> _cache = new();
|
||||
private readonly HashSet<string> _blacklist = new();
|
||||
|
||||
public Task<T?> GetAsync<T>(string key, CancellationToken cancellationToken = default) where T : class
|
||||
{
|
||||
if (_cache.TryGetValue(key, out var entry))
|
||||
{
|
||||
if (entry.Expiry == null || entry.Expiry > DateTime.UtcNow)
|
||||
{
|
||||
return Task.FromResult((T?)entry.Value);
|
||||
}
|
||||
_cache.Remove(key);
|
||||
}
|
||||
return Task.FromResult<T?>(null);
|
||||
}
|
||||
|
||||
public Task<string?> GetStringAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_cache.TryGetValue(key, out var entry))
|
||||
{
|
||||
if (entry.Expiry == null || entry.Expiry > DateTime.UtcNow)
|
||||
{
|
||||
return Task.FromResult((string?)entry.Value);
|
||||
}
|
||||
_cache.Remove(key);
|
||||
}
|
||||
return Task.FromResult<string?>(null);
|
||||
}
|
||||
|
||||
public Task SetAsync<T>(string key, T value, TimeSpan? expiration = null, CancellationToken cancellationToken = default) where T : class
|
||||
{
|
||||
var expiry = expiration.HasValue ? DateTime.UtcNow.Add(expiration.Value) : (DateTime?)null;
|
||||
_cache[key] = (value, expiry);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task SetStringAsync(string key, string value, TimeSpan? expiration = null, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var expiry = expiration.HasValue ? DateTime.UtcNow.Add(expiration.Value) : (DateTime?)null;
|
||||
_cache[key] = (value, expiry);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RemoveAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_cache.Remove(key);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task RemoveByPatternAsync(string pattern, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var keysToRemove = _cache.Keys.Where(k => k.Contains(pattern.Replace("*", ""))).ToList();
|
||||
foreach (var key in keysToRemove)
|
||||
{
|
||||
_cache.Remove(key);
|
||||
}
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<bool> ExistsAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (_cache.TryGetValue(key, out var entry))
|
||||
{
|
||||
if (entry.Expiry == null || entry.Expiry > DateTime.UtcNow)
|
||||
{
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
_cache.Remove(key);
|
||||
}
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
public async Task<T?> GetOrSetAsync<T>(string key, Func<Task<T>> factory, TimeSpan? expiration = null, CancellationToken cancellationToken = default) where T : class
|
||||
{
|
||||
var cached = await GetAsync<T>(key, cancellationToken);
|
||||
if (cached != null) return cached;
|
||||
|
||||
var value = await factory();
|
||||
await SetAsync(key, value, expiration, cancellationToken);
|
||||
return value;
|
||||
}
|
||||
|
||||
public Task BlacklistAsync(string key, TimeSpan expiration, CancellationToken cancellationToken = default)
|
||||
{
|
||||
_blacklist.Add(key);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public Task<bool> IsBlacklistedAsync(string key, CancellationToken cancellationToken = default)
|
||||
{
|
||||
return Task.FromResult(_blacklist.Contains(key));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ public class ChangePasswordCommandHandlerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_WithWrongCurrentPassword_ShouldThrowException()
|
||||
public async Task Handle_WithWrongCurrentPassword_ShouldReturnFailure()
|
||||
{
|
||||
// Arrange
|
||||
var userId = Guid.NewGuid();
|
||||
@@ -83,8 +83,11 @@ public class ChangePasswordCommandHandlerTests
|
||||
.ReturnsAsync(IdentityResult.Failed(
|
||||
new IdentityError { Code = "PasswordMismatch", Description = "Incorrect password" }));
|
||||
|
||||
// Act & Assert
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
_handler.Handle(command, CancellationToken.None));
|
||||
// Act
|
||||
var result = await _handler.Handle(command, CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.False(result.Success);
|
||||
Assert.Contains("Incorrect password", result.Message);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user