- Add Notification entity, SignalR hub and NotificationDispatcher - Add Obsidian document endpoints and document extractors - Add RagRetrievalService with chunking/retrieval config - Add ProcessKnowledgeBase and UpdateKnowledgeBase endpoints - Add EF migrations for RAG enhancements, chunking modes and notification center - Add unit/integration tests project
129 lines
5.6 KiB
C#
129 lines
5.6 KiB
C#
using FluentAssertions;
|
||
using Microsoft.EntityFrameworkCore;
|
||
using RAG.Application.Notifications;
|
||
using RAG.Application.Notifications.Commands;
|
||
using RAG.Domain.Entities;
|
||
using RAG.Infrastructure.Persistence;
|
||
using Xunit;
|
||
|
||
namespace RAG.Application.Tests.Notifications;
|
||
|
||
/// <summary>
|
||
/// 验证消息中心 Fanout 扇出策略:一条业务消息为每个接收人生成一条独立记录,
|
||
/// 支持按用户 ID 精确指定与按角色展开,去重后落库。
|
||
/// </summary>
|
||
public class PublishNotificationCommandTests
|
||
{
|
||
private static async Task<(RagDbContext db, PublishNotificationCommandHandler handler, List<(Guid id, string role)> users)>
|
||
SetupAsync(string? roleName = "Admin")
|
||
{
|
||
var options = new DbContextOptionsBuilder<RagDbContext>()
|
||
.UseInMemoryDatabase($"notifications-{Guid.NewGuid()}")
|
||
.Options;
|
||
var db = new RagDbContext(options);
|
||
var dispatcher = new NoOpDispatcher();
|
||
var handler = new PublishNotificationCommandHandler(db, dispatcher);
|
||
|
||
// 构造角色 + 用户(用于角色展开测试)。审计字段在测试中手动填充(生产由 AuditInterceptor 填充)
|
||
var role = Audit(new Role { Id = Guid.NewGuid(), Name = roleName ?? "Admin" });
|
||
var user1 = Audit(new User { Id = Guid.NewGuid(), Username = "u1", Email = "u1@e.com", PasswordHash = "x" });
|
||
var user2 = Audit(new User { Id = Guid.NewGuid(), Username = "u2", Email = "u2@e.com", PasswordHash = "x" });
|
||
db.Roles.Add(role);
|
||
db.Users.AddRange(user1, user2);
|
||
db.UserRoles.AddRange(
|
||
Audit(new UserRole { UserId = user1.Id, RoleId = role.Id }),
|
||
Audit(new UserRole { UserId = user2.Id, RoleId = role.Id }));
|
||
await db.SaveChangesAsync();
|
||
|
||
return (db, handler, new() { (user1.Id, roleName ?? "Admin"), (user2.Id, roleName ?? "Admin") });
|
||
}
|
||
|
||
/// <summary>填充 IAuditable / IHasOperatorIP 审计字段(测试中绕过 AuditInterceptor)。</summary>
|
||
private static T Audit<T>(T entity) where T : RAG.Domain.Common.IAuditable
|
||
{
|
||
var now = DateTime.UtcNow;
|
||
entity.CreatedBy = "test";
|
||
entity.CreatedAt = now;
|
||
entity.UpdatedBy = "test";
|
||
entity.UpdatedAt = now;
|
||
// IFullAudit 实体还要求 OperatorIP 非空
|
||
if (entity is RAG.Domain.Common.IHasOperatorIP hasIp)
|
||
hasIp.OperatorIP = "127.0.0.1";
|
||
return entity;
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Publish_with_explicit_user_ids_creates_one_record_per_recipient()
|
||
{
|
||
var (db, handler, users) = await SetupAsync();
|
||
var recipientIds = users.Select(u => u.id).ToList();
|
||
|
||
var count = await handler.Handle(new PublishNotificationCommand(
|
||
Type: "system", Title: "你好", Content: "测试通知", Source: "test",
|
||
RelatedId: null, RelatedType: null,
|
||
RecipientUserIds: recipientIds, RecipientRoles: null), default);
|
||
|
||
count.Should().Be(2);
|
||
var notifications = await db.Notifications.ToListAsync();
|
||
notifications.Should().HaveCount(2);
|
||
notifications.Select(n => n.RecipientUserId).Should().BeEquivalentTo(recipientIds);
|
||
notifications.All(n => !n.IsRead && n.Title == "你好").Should().BeTrue();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Publish_by_role_expands_to_all_users_with_that_role()
|
||
{
|
||
var (db, handler, users) = await SetupAsync(roleName: "Admin");
|
||
|
||
var count = await handler.Handle(new PublishNotificationCommand(
|
||
Type: "task-arrived", Title: "新待办", Content: "请处理", Source: "workflow",
|
||
RelatedId: "inst-123", RelatedType: "workflow-instance",
|
||
RecipientUserIds: null, RecipientRoles: new List<string> { "Admin" }), default);
|
||
|
||
count.Should().Be(2);
|
||
var notifications = await db.Notifications.ToListAsync();
|
||
notifications.Should().HaveCount(2);
|
||
notifications.All(n => n.Source == "workflow" && n.RelatedId == "inst-123").Should().BeTrue();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Publish_dedupes_when_user_both_in_ids_and_role()
|
||
{
|
||
var (db, handler, users) = await SetupAsync(roleName: "Admin");
|
||
var firstUser = users[0].id;
|
||
|
||
// 该用户既在 RecipientUserIds 又拥有 Admin 角色 → 只落一条
|
||
var count = await handler.Handle(new PublishNotificationCommand(
|
||
Type: "system", Title: "去重测试", Content: "只一条", Source: "test",
|
||
RelatedId: null, RelatedType: null,
|
||
RecipientUserIds: new List<Guid> { firstUser },
|
||
RecipientRoles: new List<string> { "Admin" }), default);
|
||
|
||
// 两个 Admin 用户都该收到 = 2 条(去重发生在同一用户内)
|
||
count.Should().Be(2);
|
||
var notifications = await db.Notifications.Where(n => n.RecipientUserId == firstUser).ToListAsync();
|
||
notifications.Should().HaveCount(1);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Publish_with_no_recipients_returns_zero()
|
||
{
|
||
var (db, handler, _) = await SetupAsync();
|
||
|
||
var count = await handler.Handle(new PublishNotificationCommand(
|
||
Type: "system", Title: "无人接收", Content: "x", Source: "test",
|
||
RelatedId: null, RelatedType: null,
|
||
RecipientUserIds: null, RecipientRoles: null), default);
|
||
|
||
count.Should().Be(0);
|
||
(await db.Notifications.ToListAsync()).Should().BeEmpty();
|
||
}
|
||
|
||
/// <summary>空操作分发器:测试中不依赖 SignalR,只验证落库。</summary>
|
||
private sealed class NoOpDispatcher : INotificationDispatcher
|
||
{
|
||
public Task DispatchAsync(IReadOnlyList<Notification> notifications, CancellationToken ct = default)
|
||
=> Task.CompletedTask;
|
||
}
|
||
}
|