- 为平台测试添加EntityFrameworkCore和Xunit引用 - 实现PlatformTestDb创建带审计字段自动填充的测试数据库 - 添加DictionaryConfiguration支持配置值获取测试 - 实现完整的密码强度验证规则 - 添加RAG问答全链路端到端测试 - 创建多个Stub服务用于解耦外部依赖测试 - 实现测试专用的当前用户上下文
179 lines
7.6 KiB
C#
179 lines
7.6 KiB
C#
using Microsoft.EntityFrameworkCore;
|
||
using Microsoft.Extensions.Configuration;
|
||
using Microsoft.Extensions.DependencyInjection;
|
||
using Microsoft.Extensions.Primitives;
|
||
using NSubstitute;
|
||
using BuildFlow.Platform.Domain.Entities;
|
||
using BuildFlow.Platform.Infrastructure.Persistence;
|
||
|
||
namespace BuildFlow.Platform.Tests;
|
||
|
||
/// <summary>
|
||
/// Platform 模块测试基础设施:InMemory PlatformDbContext 工厂 + JWT 配置构造 + 审计字段填充 helper。
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// 照抄 <c>MenuSortTests.BuildInMemoryDb</c> 的 DI 方式(不挂 AuditInterceptor,手动填审计字段)。
|
||
/// 不挂拦截器是因为拦截器依赖 ICurrentUserContext/sp.GetRequiredService,测试上下文里没有;
|
||
/// 故约定:测试 seed 实体时调用 <see cref="WithAudit"/> 填充必填审计字段。
|
||
/// </remarks>
|
||
internal static class PlatformTestDb
|
||
{
|
||
/// <summary>构造一个独立的 InMemory PlatformDbContext(每个测试唯一库名,避免串扰)。</summary>
|
||
/// <remarks>
|
||
/// 挂载 SavingChanges 回调自动填充 IAuditable/IHasOperatorIP 字段(替代生产环境的 AuditInterceptor),
|
||
/// 这样 handler 内部 new 出来的 RefreshToken/UserRole/RolePermission 等实体也能通过非空校验。
|
||
/// </remarks>
|
||
public static PlatformDbContext CreateDbContext([System.Runtime.CompilerServices.CallerMemberName] string testName = "")
|
||
{
|
||
var services = new ServiceCollection();
|
||
services.AddDbContext<PlatformDbContext>(o => o.UseInMemoryDatabase($"platform-{testName}-{Guid.NewGuid()}"));
|
||
var db = services.BuildServiceProvider().GetRequiredService<PlatformDbContext>();
|
||
db.SavingChanges += AutoFillAuditFields;
|
||
return db;
|
||
}
|
||
|
||
/// <summary>SavingChanges 回调:自动填充审计字段,模拟生产 AuditInterceptor 行为。</summary>
|
||
private static void AutoFillAuditFields(object? sender, EventArgs _)
|
||
{
|
||
if (sender is not Microsoft.EntityFrameworkCore.DbContext ctx) return;
|
||
foreach (var entry in ctx.ChangeTracker.Entries())
|
||
{
|
||
if (entry.Entity is BuildFlow.Shared.Domain.Common.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 BuildFlow.Shared.Domain.Common.IHasOperatorIP o)
|
||
{
|
||
o.OperatorIP ??= "127.0.0.1";
|
||
}
|
||
}
|
||
}
|
||
|
||
/// <summary>构造测试用 IConfiguration(提供 JWT 签发所需的 SigningKey/Issuer/Audience)。</summary>
|
||
/// <remarks>
|
||
/// 用字典实现而非 NSubstitute mock:因为 <c>GetValue<T></c> 是扩展方法,
|
||
/// 内部走 ConfigurationBinder 对 this[key] 做类型转换,mock 对未设置的 key 返回空串会导致 Int32 解析失败。
|
||
/// 字典实现让未设置的 key 返回 null,使扩展方法回退到默认值。
|
||
/// </remarks>
|
||
public static IConfiguration CreateConfig() => new DictionaryConfiguration
|
||
{
|
||
["Jwt:SigningKey"] = "TestSigningKeyMustBeAtLeast32CharsLong!!",
|
||
["Jwt:Issuer"] = "test-issuer",
|
||
["Jwt:Audience"] = "test-audience",
|
||
["Jwt:AccessTokenMinutes"] = "240",
|
||
["Jwt:RefreshTokenDays"] = "7",
|
||
};
|
||
|
||
/// <summary>填充 IFullAudit 实体的必填审计字段,避免 EF 保存时非空校验失败。</summary>
|
||
public static T WithAudit<T>(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;
|
||
}
|
||
|
||
/// <summary>构造一个已持久化的测试用户(含 BCrypt 密码哈希)。</summary>
|
||
public static async Task<User> SeedUserAsync(
|
||
PlatformDbContext db,
|
||
Guid id,
|
||
string username,
|
||
string password = "Admin@123",
|
||
bool isActive = true,
|
||
int permissionVersion = 0)
|
||
{
|
||
var user = WithAudit(new User
|
||
{
|
||
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;
|
||
}
|
||
}
|
||
|
||
/// <summary>
|
||
/// 极简字典实现的 IConfiguration,仅供测试使用。
|
||
/// </summary>
|
||
/// <remarks>
|
||
/// 完整实现 GetSection/GetChildren,让 GetValue<T> 扩展方法(走 ConfigurationBinder)能正确工作。
|
||
/// 注意:ConfigurationBinder.GetValue 内部调用 GetSection(key).Value,故必须返回有效 section。
|
||
/// </remarks>
|
||
internal sealed class DictionaryConfiguration : IConfiguration
|
||
{
|
||
private readonly Dictionary<string, string?> _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<IConfigurationSection> 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<IConfigurationSection> IConfiguration.GetChildren() => [];
|
||
IChangeToken IConfiguration.GetReloadToken() => EmptyChangeToken.Instance;
|
||
}
|
||
|
||
/// <summary>永不变更的占位 IChangeToken,避免引入额外包依赖。</summary>
|
||
private sealed class EmptyChangeToken : IChangeToken
|
||
{
|
||
public static readonly EmptyChangeToken Instance = new();
|
||
public bool ActiveChangeCallbacks => false;
|
||
public bool HasChanged => false;
|
||
public IDisposable RegisterChangeCallback(Action<object> callback, object? state) =>
|
||
new NoopDisposable();
|
||
private sealed class NoopDisposable : IDisposable
|
||
{
|
||
public void Dispose() { }
|
||
}
|
||
}
|
||
}
|
||
|