test(platform): 添加平台模块测试基础设施和RAG端到端测试
- 为平台测试添加EntityFrameworkCore和Xunit引用 - 实现PlatformTestDb创建带审计字段自动填充的测试数据库 - 添加DictionaryConfiguration支持配置值获取测试 - 实现完整的密码强度验证规则 - 添加RAG问答全链路端到端测试 - 创建多个Stub服务用于解耦外部依赖测试 - 实现测试专用的当前用户上下文
This commit is contained in:
parent
16ba77f69f
commit
f550074fcc
@ -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("密码必须包含至少一个数字");
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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<T></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<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() { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
164
tests/BuildFlow.Rag.Tests/RagQAEndToEndTests.cs
Normal file
164
tests/BuildFlow.Rag.Tests/RagQAEndToEndTests.cs
Normal 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("回答");
|
||||
}
|
||||
}
|
||||
32
tests/BuildFlow.Rag.Tests/Stub/StubAIChatAgent.cs
Normal file
32
tests/BuildFlow.Rag.Tests/Stub/StubAIChatAgent.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
44
tests/BuildFlow.Rag.Tests/Stub/StubChatMessageCache.cs
Normal file
44
tests/BuildFlow.Rag.Tests/Stub/StubChatMessageCache.cs
Normal 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;
|
||||
}
|
||||
36
tests/BuildFlow.Rag.Tests/Stub/StubDocumentExtractor.cs
Normal file
36
tests/BuildFlow.Rag.Tests/Stub/StubDocumentExtractor.cs
Normal 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;
|
||||
}
|
||||
36
tests/BuildFlow.Rag.Tests/Stub/StubEmbeddingService.cs
Normal file
36
tests/BuildFlow.Rag.Tests/Stub/StubEmbeddingService.cs
Normal 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());
|
||||
}
|
||||
}
|
||||
35
tests/BuildFlow.Rag.Tests/Stub/StubFileStorageClient.cs
Normal file
35
tests/BuildFlow.Rag.Tests/Stub/StubFileStorageClient.cs
Normal 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));
|
||||
}
|
||||
20
tests/BuildFlow.Rag.Tests/Stub/StubRagRetrievalService.cs
Normal file
20
tests/BuildFlow.Rag.Tests/Stub/StubRagRetrievalService.cs
Normal 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());
|
||||
}
|
||||
12
tests/BuildFlow.Rag.Tests/Stub/TestCurrentUserContext.cs
Normal file
12
tests/BuildFlow.Rag.Tests/Stub/TestCurrentUserContext.cs
Normal 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";
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user