xiangning f550074fcc test(platform): 添加平台模块测试基础设施和RAG端到端测试
- 为平台测试添加EntityFrameworkCore和Xunit引用
- 实现PlatformTestDb创建带审计字段自动填充的测试数据库
- 添加DictionaryConfiguration支持配置值获取测试
- 实现完整的密码强度验证规则
- 添加RAG问答全链路端到端测试
- 创建多个Stub服务用于解耦外部依赖测试
- 实现测试专用的当前用户上下文
2026-07-07 17:31:50 +08:00

179 lines
7.6 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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&lt;T&gt;</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&lt;T&gt; 扩展方法(走 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() { }
}
}
}