test(platform): 添加平台模块测试基础设施和RAG端到端测试

- 为平台测试添加EntityFrameworkCore和Xunit引用
- 实现PlatformTestDb创建带审计字段自动填充的测试数据库
- 添加DictionaryConfiguration支持配置值获取测试
- 实现完整的密码强度验证规则
- 添加RAG问答全链路端到端测试
- 创建多个Stub服务用于解耦外部依赖测试
- 实现测试专用的当前用户上下文
This commit is contained in:
xiangning 2026-07-07 17:31:50 +08:00
parent 16ba77f69f
commit f550074fcc
15 changed files with 497 additions and 13 deletions

View File

@ -17,6 +17,10 @@ public class RegisterCommandValidator : AbstractValidator<RegisterCommand>
.EmailAddress().WithMessage("邮箱格式不正确");
RuleFor(x => x.Password).NotEmpty().WithMessage("密码不能为空")
.Length(6, 100).WithMessage("密码长度 6-100 个字符");
.MinimumLength(8).WithMessage("密码至少 8 个字符")
.MaximumLength(100).WithMessage("密码最多 100 个字符")
.Matches("[A-Z]").WithMessage("密码必须包含至少一个大写字母")
.Matches("[a-z]").WithMessage("密码必须包含至少一个小写字母")
.Matches("[0-9]").WithMessage("密码必须包含至少一个数字");
}
}

View File

@ -1,6 +1,8 @@
using System.IdentityModel.Tokens.Jwt;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using Xunit;
using BuildFlow.Platform.Application.Auth.Commands;
using BuildFlow.Platform.Application.Auth.Handlers;
using BuildFlow.Platform.Domain.Common;

View File

@ -1,4 +1,6 @@
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Xunit;
using BuildFlow.Platform.Application.Auth.Commands;
using BuildFlow.Platform.Application.Auth.Handlers;
using BuildFlow.Shared.Domain.Exceptions;

View File

@ -1,4 +1,6 @@
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Xunit;
using BuildFlow.Platform.Application.Auth.Commands;
using BuildFlow.Platform.Application.Auth.Handlers;
using BuildFlow.Shared.Domain.Exceptions;

View File

@ -1,6 +1,7 @@
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;
@ -18,26 +19,53 @@ namespace BuildFlow.Platform.Tests;
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()}"));
return services.BuildServiceProvider().GetRequiredService<PlatformDbContext>();
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>
public static IConfiguration CreateConfig()
/// <remarks>
/// 用字典实现而非 NSubstitute mock:因为 <c>GetValue&lt;T&gt;</c> 是扩展方法,
/// 内部走 ConfigurationBinder 对 this[key] 做类型转换,mock 对未设置的 key 返回空串会导致 Int32 解析失败。
/// 字典实现让未设置的 key 返回 null,使扩展方法回退到默认值。
/// </remarks>
public static IConfiguration CreateConfig() => new DictionaryConfiguration
{
var config = Substitute.For<IConfiguration>();
config["Jwt:SigningKey"] = "TestSigningKeyMustBeAtLeast32CharsLong!!";
config["Jwt:Issuer"] = "test-issuer";
config["Jwt:Audience"] = "test-audience";
config["Jwt:AccessTokenMinutes"] = "240";
config["Jwt:RefreshTokenDays"] = "7";
// GetValue<T> 在 IConfiguration 扩展方法里,NSubstitute 无法直接 mock,LoginCommandHandler 用的是 config.GetValue("Jwt:AccessTokenMinutes", 240)
// 但由于 LoginCommandHandler 实际调用 GetValue 是扩展方法(内部走 this[key] 再 Convert),mock this[key] 即可生效
return config;
}
["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
@ -85,3 +113,66 @@ internal static class PlatformTestDb
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() { }
}
}
}

View File

@ -1,5 +1,7 @@
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using Xunit;
using BuildFlow.Platform.Application.Roles.Commands;
using BuildFlow.Platform.Application.Roles.Handlers;
using BuildFlow.Platform.Domain.Common;

View File

@ -1,5 +1,7 @@
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using NSubstitute;
using Xunit;
using BuildFlow.Platform.Application.Users.Commands;
using BuildFlow.Platform.Application.Users.Handlers;
using BuildFlow.Platform.Domain.Common;

View File

@ -0,0 +1,164 @@
using BuildFlow.Application.RagQA.Commands;
using BuildFlow.Application.RagQA.Handlers;
using BuildFlow.Domain.Entities;
using BuildFlow.Domain.Enums;
using BuildFlow.Domain.Interfaces;
using BuildFlow.Infrastructure.Persistence;
using BuildFlow.Rag.Tests.Stub;
using FluentAssertions;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace BuildFlow.Rag.Tests;
/// <summary>
/// [SOP-RAG-01] 知识库 RAG 问答全链路端到端测试。
/// 用 Stub IRagRetrievalService 返回预设 sources,绕过 pgvector 真实召回。
/// 验证:RagQueryCommandHandler 取 KB → RetrieveAsync → IAIChatAgent.RunAsync → 返回 answer+sources。
/// 飞书文档: https://ecn1rid3e27z.feishu.cn/wiki/F9S2wBS77i35bkkwJyVccDlfnyg
/// </summary>
public class RagQAEndToEndTests
{
private static async Task<RagDbContext> CreateDbAsync(string name)
{
var options = new DbContextOptionsBuilder<RagDbContext>()
.UseInMemoryDatabase($"rag-qa-{name}-{Guid.NewGuid():N}")
.Options;
await using var db = new RagDbContext(options);
return new RagDbContext(options);
}
[Fact]
public async Task Query_WithSources_ReturnsAnswerAndSources()
{
// Given: 已创建知识库
await using var db = await CreateDbAsync("with-sources");
var kb = new KnowledgeBase
{
Name = "测试知识库",
RetrievalMode = RetrievalMode.Hybrid,
RetrievalTopK = 5,
ContextTopK = 3,
SimilarityThreshold = 0.3,
VectorWeight = 0.7,
CreatedBy = "test",
UpdatedBy = "test",
OperatorIP = "127.0.0.1"
};
db.KnowledgeBases.Add(kb);
var documentId = Guid.NewGuid();
var chunkId = Guid.NewGuid();
db.Documents.Add(new Document
{
Id = documentId,
KnowledgeBaseId = kb.Id,
Title = "技术架构",
FileName = "arch.md",
FilePath = "test/arch.md",
FileSize = 100,
ContentType = "text/markdown",
Status = DocumentStatus.Completed,
CreatedBy = "test",
UpdatedBy = "test",
OperatorIP = "127.0.0.1"
});
await db.SaveChangesAsync();
// Stub RetrievalService 返回预设 sources
var retrieval = new StubRagRetrievalService
{
Hits =
[
new RetrievalHit(
documentId,
"技术架构",
chunkId,
"BuildFlow 使用 PostgreSQL + pgvector",
0.87,
"BuildFlow 使用 PostgreSQL + pgvector")
]
};
var chatAgent = new StubAIChatAgent { ReplyText = "BuildFlow 使用 pgvector 向量数据库" };
var handler = new RagQueryCommandHandler(db, retrieval, chatAgent);
// When: 调用 RAG 查询
var result = await handler.Handle(
new RAGQueryCommand(kb.Id, "BuildFlow 用了什么向量数据库?"),
CancellationToken.None);
// Then: 返回 answer + sources
result.Answer.Should().NotBeNullOrEmpty("应有 LLM 响应");
result.Answer.Should().Be("BuildFlow 使用 pgvector 向量数据库");
result.Sources.Should().NotBeEmpty("sources 非空");
result.Sources[0].DocumentId.Should().Be(documentId, "DocumentId 匹配上传文档");
result.Sources[0].Similarity.Should().BeGreaterThan(0, "相似度>0");
}
[Fact]
public async Task Query_NoSources_ReturnsNotFoundMessage()
{
// Given: 知识库存在但 RetrievalService 返回空
await using var db = await CreateDbAsync("no-sources");
var kb = new KnowledgeBase
{
Name = "空知识库",
CreatedBy = "test",
UpdatedBy = "test",
OperatorIP = "127.0.0.1"
};
db.KnowledgeBases.Add(kb);
await db.SaveChangesAsync();
var retrieval = new StubRagRetrievalService { Hits = [] };
var chatAgent = new StubAIChatAgent();
var handler = new RagQueryCommandHandler(db, retrieval, chatAgent);
// When: 查询无结果
var result = await handler.Handle(
new RAGQueryCommand(kb.Id, "不存在的内容"),
CancellationToken.None);
// Then: 返回未找到提示,sources 空
result.Sources.Should().BeEmpty("无召回结果");
result.Answer.Should().NotBeNull("应返回提示文本");
}
[Theory]
[InlineData(RetrievalMode.Vector)]
[InlineData(RetrievalMode.Keyword)]
[InlineData(RetrievalMode.Hybrid)]
public async Task Query_WorksAcrossRetrievalModes(RetrievalMode mode)
{
// Given: 不同 RetrievalMode 的知识库(Stub 不依赖真实召回算法)
await using var db = await CreateDbAsync($"mode-{mode}");
var kb = new KnowledgeBase
{
Name = $"模式测试-{mode}",
RetrievalMode = mode,
CreatedBy = "test",
UpdatedBy = "test",
OperatorIP = "127.0.0.1"
};
db.KnowledgeBases.Add(kb);
await db.SaveChangesAsync();
var documentId = Guid.NewGuid();
var retrieval = new StubRagRetrievalService
{
Hits = [new RetrievalHit(documentId, "doc", Guid.NewGuid(), "内容", 0.9, "内容")]
};
var chatAgent = new StubAIChatAgent { ReplyText = "回答" };
var handler = new RagQueryCommandHandler(db, retrieval, chatAgent);
// When
var result = await handler.Handle(new RAGQueryCommand(kb.Id, "问题"), CancellationToken.None);
// Then: 三种模式都能返回结果(Stub 绕过真实算法差异)
result.Sources.Should().HaveCount(1);
result.Answer.Should().Be("回答");
}
}

View File

@ -0,0 +1,32 @@
using BuildFlow.Domain.Interfaces;
using Microsoft.Extensions.AI;
namespace BuildFlow.Rag.Tests.Stub;
/// <summary>
/// Stub IAIChatAgent:RunAsync 返回固定文本,RunStreamingAsync yield 固定 token 序列。
/// 避免调用真实 LM Studio/OpenAI。
/// </summary>
public sealed class StubAIChatAgent : IAIChatAgent
{
/// <summary>RunAsync 返回的固定答案文本。</summary>
public string ReplyText { get; init; } = "这是来自知识库的测试答案。";
/// <summary>RunStreamingAsync 逐 token yield 的序列。</summary>
public IReadOnlyList<string> StreamTokens { get; init; } = ["你", "好", "!"];
public Task<string> RunAsync(IReadOnlyList<ChatMessage> messages, CancellationToken ct = default)
=> Task.FromResult(ReplyText);
public async IAsyncEnumerable<string> RunStreamingAsync(
IReadOnlyList<ChatMessage> messages,
[System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken ct = default)
{
foreach (var token in StreamTokens)
{
ct.ThrowIfCancellationRequested();
await Task.Yield();
yield return token;
}
}
}

View File

@ -0,0 +1,44 @@
using BuildFlow.Domain.Interfaces;
namespace BuildFlow.Rag.Tests.Stub;
/// <summary>
/// Stub IChatMessageCache:内存字典模拟 Redis cache,验证双写一致性。
/// </summary>
public sealed class StubChatMessageCache : IChatMessageCache
{
private readonly Dictionary<Guid, List<CachedChatMessage>> _store = new();
public Task<List<CachedChatMessage>?> GetMessagesAsync(Guid conversationId, CancellationToken ct = default)
{
_store.TryGetValue(conversationId, out var list);
return Task.FromResult(list);
}
public Task SetMessagesAsync(Guid conversationId, List<CachedChatMessage> messages, CancellationToken ct = default)
{
_store[conversationId] = messages.ToList();
return Task.CompletedTask;
}
public Task AppendMessageAsync(Guid conversationId, CachedChatMessage message, CancellationToken ct = default)
{
if (!_store.TryGetValue(conversationId, out var list))
{
list = [];
_store[conversationId] = list;
}
list.Add(message);
return Task.CompletedTask;
}
public Task RemoveAsync(Guid conversationId, CancellationToken ct = default)
{
_store.Remove(conversationId);
return Task.CompletedTask;
}
/// <summary>测试辅助:获取某会话缓存消息数。</summary>
public int Count(Guid conversationId)
=> _store.TryGetValue(conversationId, out var list) ? list.Count : 0;
}

View File

@ -0,0 +1,36 @@
using BuildFlow.Domain.Interfaces;
namespace BuildFlow.Rag.Tests.Stub;
/// <summary>
/// Stub IDocumentExtractorFactory + IDocumentExtractor:返回固定提取文本。
/// 绕过真实文档解析(PDF/Word/Html)。
/// </summary>
public sealed class StubDocumentExtractor : IDocumentExtractor
{
/// <summary>提取返回的固定文本。</summary>
public string ExtractedContent { get; init; } = "BuildFlow 使用 PostgreSQL + pgvector 作为向量数据库。.NET 10 模块化单体架构。";
/// <summary>提取返回的标题。</summary>
public string ExtractedTitle { get; init; } = "测试文档";
public IReadOnlyCollection<string> SupportedExtensions { get; init; } = ["md", "txt", "pdf", "docx"];
public Task<ExtractedDocument> ExtractAsync(string filePath, CancellationToken ct = default)
=> Task.FromResult(new ExtractedDocument
{
Content = ExtractedContent,
Title = ExtractedTitle,
SourcePath = filePath
});
}
/// <summary>
/// 工厂:统一返回 StubDocumentExtractor。
/// </summary>
public sealed class StubDocumentExtractorFactory : IDocumentExtractorFactory
{
public StubDocumentExtractor InnerExtractor { get; } = new();
public IDocumentExtractor GetExtractor(string fileNameOrExtension) => InnerExtractor;
}

View File

@ -0,0 +1,36 @@
using BuildFlow.Domain.Interfaces;
namespace BuildFlow.Rag.Tests.Stub;
/// <summary>
/// Stub IEmbeddingService:返回固定维度(768)的向量,与 DB vector(768) 列匹配。
/// 维度由 embeddinggemma-300m 决定。所有文本返回相同向量(测试场景下足够)。
/// </summary>
public sealed class StubEmbeddingService : IEmbeddingService
{
/// <summary>向量维度,匹配 document_chunks.embedding vector(768)。</summary>
public const int Dimensions = 768;
/// <summary>是否返回空向量(用于测试零分块路径)。</summary>
public bool ReturnEmpty { get; init; }
private static float[] BuildVector()
{
var v = new float[Dimensions];
for (var i = 0; i < Dimensions; i++)
v[i] = (i % 10) / 10f;
return v;
}
public Task<float[]> EmbedAsync(string text, CancellationToken ct = default)
=> Task.FromResult(ReturnEmpty ? [] : BuildVector());
public Task<List<float[]>> EmbedBatchAsync(List<string> texts, CancellationToken ct = default)
{
if (ReturnEmpty)
return Task.FromResult(new List<float[]>());
var vector = BuildVector();
return Task.FromResult(texts.Select(_ => vector).ToList());
}
}

View File

@ -0,0 +1,35 @@
using BuildFlow.Domain.Interfaces;
namespace BuildFlow.Rag.Tests.Stub;
/// <summary>
/// Stub IFileStorageClient:Upload 记录调用,Download 返回内存流。
/// 绕过真实对象存储(RustFS/MinIO)。
/// </summary>
public sealed class StubFileStorageClient : IFileStorageClient
{
/// <summary>记录所有上传调用 (bucket, objectKey, size)。</summary>
public List<(string Bucket, string ObjectKey, long Size)> Uploads { get; } = [];
/// <summary>Download 返回的内容(UTF-8 字节)。默认空数组。</summary>
public byte[] DownloadContent { get; init; } = [];
public Task UploadAsync(
string bucket,
string objectKey,
Stream content,
string contentType,
string? authToken,
CancellationToken ct = default)
{
Uploads.Add((bucket, objectKey, content.Length));
return Task.CompletedTask;
}
public Task<Stream> DownloadAsync(
string bucket,
string objectKey,
string? authToken,
CancellationToken ct = default)
=> Task.FromResult<Stream>(new MemoryStream(DownloadContent));
}

View File

@ -0,0 +1,20 @@
using BuildFlow.Domain.Entities;
using BuildFlow.Domain.Interfaces;
namespace BuildFlow.Rag.Tests.Stub;
/// <summary>
/// Stub IRagRetrievalService:返回预设 sources,绕过 pgvector 真实召回。
/// POC 阶段用,后续 Testcontainers 集成测试覆盖真实召回。
/// </summary>
public sealed class StubRagRetrievalService : IRagRetrievalService
{
/// <summary>预设的召回结果。</summary>
public IReadOnlyList<RetrievalHit> Hits { get; init; } = [];
public Task<List<RetrievalHit>> RetrieveAsync(
KnowledgeBase knowledgeBase,
string question,
CancellationToken ct = default)
=> Task.FromResult(Hits.ToList());
}

View File

@ -0,0 +1,12 @@
using BuildFlow.Shared.Domain.Common;
namespace BuildFlow.Rag.Tests.Stub;
/// <summary>
/// 测试用当前用户上下文。UserId 必须非空且非 "system",否则 GetRequiredUserId 抛 UnauthorizedException。
/// </summary>
public sealed class TestCurrentUserContext : ICurrentUserContext
{
public string UserId { get; init; } = "11111111-1111-1111-1111-111111111111";
public string IPAddress { get; init; } = "127.0.0.1";
}