using BuildFlow.Platform.Domain.Entities.User; using BuildFlow.Platform.Infrastructure.Persistence; using BuildFlow.Shared.Domain.Common; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Primitives; using Microsoft.Extensions.DependencyInjection; namespace BuildFlow.Platform.Tests; /// /// Platform 模块测试基础设施:InMemory PlatformDbContext 工厂 + JWT 配置构造 + 审计字段填充 helper。 /// /// /// 不挂 AuditInterceptor(拦截器依赖 ICurrentUserContext,测试上下文里没有); /// 约定:测试 seed 实体时调用 填充必填审计字段。 /// internal static class PlatformTestDb { /// 构造一个独立的 InMemory PlatformDbContext(每个测试唯一库名,避免串扰)。 /// /// 挂载 SavingChanges 回调自动填充 IAuditable/IHasOperatorIP 字段(替代生产环境的 AuditInterceptor), /// 这样 handler 内部 new 出来的 RefreshTokenEntity/UserRoleEntity/RolePermissionEntity 等实体也能通过非空校验。 /// public static PlatformDbContext CreateDbContext([System.Runtime.CompilerServices.CallerMemberName] string testName = "") { var services = new ServiceCollection(); services.AddDbContext(o => o.UseInMemoryDatabase($"platform-{testName}-{Guid.NewGuid()}")); var db = services.BuildServiceProvider().GetRequiredService(); db.SavingChanges += AutoFillAuditFields; return db; } /// SavingChanges 回调:自动填充审计字段,模拟生产 AuditInterceptor 行为。 private static void AutoFillAuditFields(object? sender, EventArgs _) { if (sender is not DbContext ctx) return; foreach (var entry in ctx.ChangeTracker.Entries()) { if (entry.Entity is IAuditable a) { a.CreatedBy ??= "test"; a.UpdatedBy ??= "test"; if (a.CreatedAt == default) a.CreatedAt = DateTime.UtcNow; if (a.UpdatedAt == default) a.UpdatedAt = DateTime.UtcNow; } if (entry.Entity is IHasOperatorIP o) o.OperatorIP ??= "127.0.0.1"; } } /// 构造测试用 IConfiguration(提供 JWT 签发所需的 SigningKey/Issuer/Audience)。 /// 用字典实现而非 NSubstitute mock:因为 GetValue<T> 是扩展方法, /// 内部走 ConfigurationBinder 对 this[key] 做类型转换,mock 对未设置的 key 返回空串会导致 Int32 解析失败。 /// 字典实现让未设置的 key 返回 null,使扩展方法回退到默认值。 public static IConfiguration CreateConfig() => new DictionaryConfiguration { ["Jwt:SigningKey"] = "TestSigningKeyMustBeAtLeast32CharsLong!!", ["Jwt:Issuer"] = "test-issuer", ["Jwt:Audience"] = "test-audience", ["Jwt:AccessTokenMinutes"] = "240", ["Jwt:RefreshTokenDays"] = "7", }; /// 填充 IFullAudit 实体的必填审计字段,避免 EF 保存时非空校验失败。 public static T WithAudit(T entity) where T : class { var createdBy = entity.GetType().GetProperty("CreatedBy"); var updatedBy = entity.GetType().GetProperty("UpdatedBy"); var operatorIP = entity.GetType().GetProperty("OperatorIP"); var createdAt = entity.GetType().GetProperty("CreatedAt"); var updatedAt = entity.GetType().GetProperty("UpdatedAt"); if (createdBy is not null && createdBy.GetValue(entity) is null or "") createdBy.SetValue(entity, "test"); if (updatedBy is not null && updatedBy.GetValue(entity) is null or "") updatedBy.SetValue(entity, "test"); if (operatorIP is not null && operatorIP.GetValue(entity) is null or "") operatorIP.SetValue(entity, "127.0.0.1"); if (createdAt is not null && (DateTime)createdAt.GetValue(entity)! == default) createdAt.SetValue(entity, DateTime.UtcNow); if (updatedAt is not null && (DateTime)updatedAt.GetValue(entity)! == default) updatedAt.SetValue(entity, DateTime.UtcNow); return entity; } /// 构造一个已持久化的测试用户(含 BCrypt 密码哈希)。 public static async Task SeedUserAsync( PlatformDbContext db, Guid id, string username, string password = "Admin@123", bool isActive = true, int permissionVersion = 0) { var user = WithAudit(new UserEntity { Id = id, Username = username, Email = $"{username}@test.local", PasswordHash = BCrypt.Net.BCrypt.HashPassword(password), IsActive = isActive, PermissionVersion = permissionVersion, }); db.Users.Add(user); await db.SaveChangesAsync(); return user; } } /// 极简字典实现的 IConfiguration,仅供测试使用。 /// 完整实现 GetSection/GetChildren,让 GetValue<T> 扩展方法(走 ConfigurationBinder)能正确工作。 /// 注意:ConfigurationBinder.GetValue 内部调用 GetSection(key).Value,故必须返回有效 section。 internal sealed class DictionaryConfiguration : IConfiguration { private readonly Dictionary _values = []; public string? this[string key] { get => _values.TryGetValue(key, out var v) ? v : null; set => _values[key] = value; } // 支持集合初始化器语法:new DictionaryConfiguration { ["k"] = "v" } public void Add(string key, string? value) => _values[key] = value; IEnumerable IConfiguration.GetChildren() => _values.Select(kv => (IConfigurationSection)new LeafSection(kv.Key, kv.Value)).ToList(); IChangeToken IConfiguration.GetReloadToken() => EmptyChangeToken.Instance; IConfigurationSection IConfiguration.GetSection(string key) => new LeafSection(key, _values.TryGetValue(key, out var v) ? v : null); private sealed class LeafSection(string key, string? value) : IConfigurationSection { public string Key => key; public string Path => key; public string? Value { get; set; } = value; string? IConfiguration.this[string childKey] { get => null; set { } } IConfigurationSection IConfiguration.GetSection(string childKey) => new LeafSection($"{key}:{childKey}", null); IEnumerable IConfiguration.GetChildren() => []; IChangeToken IConfiguration.GetReloadToken() => EmptyChangeToken.Instance; } /// 永不变更的占位 IChangeToken,避免引入额外包依赖。 private sealed class EmptyChangeToken : IChangeToken { public static readonly EmptyChangeToken Instance = new(); public bool ActiveChangeCallbacks => false; public bool HasChanged => false; public IDisposable RegisterChangeCallback(Action callback, object? state) => new NoopDisposable(); private sealed class NoopDisposable : IDisposable { public void Dispose() { } } } }