feat: 完成云同步、语音控制与多平台扩展基础架构搭建

本次提交完成了项目核心基础架构升级:
1. 新增动态API中间件与权限控制系统,支持匿名/鉴权接口分离
2. 搭建云同步服务体系,包含认证、任务同步、安全策略等核心模块
3. 实现语音控制全链路,从STT/意图解析到命令执行
4. 新增任务类型、附件实体与相关仓储接口
5. 重构前端配置与代理规则,统一后端端口为5057
6. 新增多平台测试项目与CI脚本优化
7. 完善项目文档与代码注释规范

移除了旧版迁移文件与冗余代理配置,调整项目结构适配跨平台部署需求。
This commit is contained in:
ShaoHua
2026-06-21 03:26:04 +08:00
parent 65cee20006
commit 4fe0b5a963
200 changed files with 12728 additions and 3550 deletions
@@ -0,0 +1,249 @@
using Hua.Todo.Application.Models;
using Hua.Todo.Core.Entities;
using Xunit;
namespace Hua.Todo.Host.Tests.Attachments;
/// <summary>
/// 工单 04 附件与描述数据模型测试。
/// </summary>
public class AttachmentModelTests
{
[Fact]
public void AttachmentEntity_DefaultValues_AreCorrect()
{
var entity = new AttachmentEntity();
Assert.Equal(Guid.Empty, entity.Id);
Assert.Equal(Guid.Empty, entity.TaskId);
Assert.Equal(string.Empty, entity.FileName);
Assert.Equal(string.Empty, entity.FilePath);
Assert.Equal(0L, entity.FileSize);
Assert.Equal(string.Empty, entity.ContentType);
Assert.Equal(AttachmentType.LocalFile, entity.AttachmentType);
Assert.True(entity.CreatedAt <= DateTime.UtcNow);
}
[Fact]
public void AttachmentType_LocalFile_IsZero()
{
Assert.Equal(0, (int)AttachmentType.LocalFile);
}
[Fact]
public void AttachmentType_ExternalLink_IsOne()
{
Assert.Equal(1, (int)AttachmentType.ExternalLink);
}
[Fact]
public void AttachmentEntity_CanBeCreatedWithValues()
{
var id = Guid.NewGuid();
var taskId = Guid.NewGuid();
var entity = new AttachmentEntity
{
Id = id,
TaskId = taskId,
FileName = "需求文档.pdf",
FilePath = "/attachments/42_需求文档.pdf",
FileSize = 204800,
ContentType = "application/pdf",
AttachmentType = AttachmentType.LocalFile,
CreatedAt = new DateTime(2026, 6, 16, 10, 0, 0, DateTimeKind.Utc)
};
Assert.Equal(id, entity.Id);
Assert.Equal(taskId, entity.TaskId);
Assert.Equal("需求文档.pdf", entity.FileName);
Assert.Equal("/attachments/42_需求文档.pdf", entity.FilePath);
Assert.Equal(204800L, entity.FileSize);
Assert.Equal("application/pdf", entity.ContentType);
Assert.Equal(AttachmentType.LocalFile, entity.AttachmentType);
Assert.Equal(new DateTime(2026, 6, 16, 10, 0, 0, DateTimeKind.Utc), entity.CreatedAt);
}
[Fact]
public void AttachmentEntity_ExternalLinkType_HasDefaultFileSizeZero()
{
var entity = new AttachmentEntity
{
AttachmentType = AttachmentType.ExternalLink,
FilePath = "https://example.com/doc"
};
Assert.Equal(0L, entity.FileSize);
Assert.Equal(string.Empty, entity.ContentType);
}
[Fact]
public void AttachmentDto_Properties_AreMapped()
{
var id = Guid.NewGuid();
var taskId = Guid.NewGuid();
var dto = new AttachmentDto
{
Id = id,
TaskId = taskId,
FileName = "test.txt",
FilePath = "/path/to/test.txt",
FileSize = 1024,
ContentType = "text/plain",
AttachmentType = AttachmentType.LocalFile,
CreatedAt = DateTime.UtcNow
};
Assert.Equal(id, dto.Id);
Assert.Equal(taskId, dto.TaskId);
Assert.Equal("test.txt", dto.FileName);
Assert.Equal(1024L, dto.FileSize);
Assert.Equal("text/plain", dto.ContentType);
Assert.Equal(AttachmentType.LocalFile, dto.AttachmentType);
}
[Fact]
public void UploadAttachmentRequest_CanBeCreated()
{
var taskId = Guid.NewGuid();
var request = new UploadAttachmentRequest
{
TaskId = taskId,
FileName = "report.pdf",
Base64Content = "dGVzdA==",
ContentType = "application/pdf"
};
Assert.Equal(taskId, request.TaskId);
Assert.Equal("report.pdf", request.FileName);
Assert.Equal("dGVzdA==", request.Base64Content);
Assert.Equal("application/pdf", request.ContentType);
}
[Fact]
public void AddLinkRequest_CanBeCreated()
{
var taskId = Guid.NewGuid();
var request = new AddLinkRequest
{
TaskId = taskId,
Url = "https://example.com/doc",
FileName = "参考文档"
};
Assert.Equal(taskId, request.TaskId);
Assert.Equal("https://example.com/doc", request.Url);
Assert.Equal("参考文档", request.FileName);
}
[Fact]
public void AddLinkRequest_FileName_CanBeNull()
{
var taskId = Guid.NewGuid();
var request = new AddLinkRequest
{
TaskId = taskId,
Url = "https://example.com/doc"
};
Assert.Null(request.FileName);
}
[Fact]
public void TaskEntity_DefaultDescription_IsNull()
{
var entity = new TaskEntity();
Assert.Null(entity.Description);
}
[Fact]
public void TaskEntity_Description_CanBeSet()
{
var entity = new TaskEntity
{
Description = "这是一个多行描述,用于记录详细说明。"
};
Assert.Equal("这是一个多行描述,用于记录详细说明。", entity.Description);
}
[Fact]
public void TaskEntity_DefaultAttachments_IsEmpty()
{
var entity = new TaskEntity();
Assert.NotNull(entity.Attachments);
Assert.Empty(entity.Attachments);
}
[Fact]
public void TaskDto_IncludesDescription()
{
var dto = new TaskDto
{
Description = "测试描述"
};
Assert.Equal("测试描述", dto.Description);
}
[Fact]
public void TaskDto_IncludesAttachmentCount()
{
var dto = new TaskDto
{
AttachmentCount = 3
};
Assert.Equal(3, dto.AttachmentCount);
}
[Fact]
public void CreateTaskDto_IncludesDescription()
{
var dto = new CreateTaskDto
{
Title = "测试任务",
Description = "描述内容"
};
Assert.Equal("描述内容", dto.Description);
}
[Fact]
public void UpdateTaskDto_Description_IsOptional()
{
var dto = new UpdateTaskDto
{
Id = Guid.NewGuid(),
Title = "更新标题"
// 不传 Description —— 保持原值
};
Assert.Null(dto.Description);
}
[Fact]
public void OpenAttachmentResponse_OpenedProperty()
{
var response = new OpenAttachmentResponse { Opened = true };
Assert.True(response.Opened);
var failed = new OpenAttachmentResponse { Opened = false };
Assert.False(failed.Opened);
}
[Fact]
public void Description_Property_IsDefinedOnTaskEntity()
{
var property = typeof(TaskEntity).GetProperty("Description");
Assert.NotNull(property);
Assert.Equal(typeof(string), property.PropertyType);
}
[Fact]
public void Attachments_Property_IsDefinedOnTaskEntity()
{
var property = typeof(TaskEntity).GetProperty("Attachments");
Assert.NotNull(property);
Assert.Equal(typeof(List<AttachmentEntity>), property.PropertyType);
}
}
@@ -0,0 +1,138 @@
using System.Net;
using System.Net.Http.Headers;
using System.Net.Http.Json;
using System.Text.Json;
using Hua.Todo.Application.Common;
using Hua.Todo.Application.Repositories;
using Hua.Todo.Application.Services.CloudSync.Models;
using Hua.Todo.Core.Entities;
using Hua.Todo.HttpApi.AspNetCore.CloudSync.Services;
using Hua.Todo.HttpApi.AspNetCore.DynamicApi;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.AspNetCore.Identity;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace Hua.Todo.Host.Tests.CloudSync;
/// <summary>
/// CloudSync 登录后访问安全策略的端到端测试。
/// 覆盖 DynamicApi 响应包装、SessionAuthenticationHandler 认证和 SecurityPolicyService 当前用户解析链路。
/// </summary>
public sealed class CloudSyncAuthSecurityPolicyTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly WebApplication _app;
private readonly HttpClient _client;
/// <summary>
/// 创建使用 SQLite 内存库的最小 Host 管道。
/// </summary>
public CloudSyncAuthSecurityPolicyTests()
{
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Services.AddApplicationServices("Data Source=:memory:");
builder.Services.AddDbContext<TodoDbContext>(options => options.UseSqlite(_connection));
builder.Services.AddCloudSyncServer();
_app = builder.Build();
_app.UseAuthentication();
_app.UseAuthorization();
_app.UseDynamicApi();
using (var scope = _app.Services.CreateScope())
{
var db = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
db.Database.EnsureCreated();
SeedAdmin(db, scope.ServiceProvider.GetRequiredService<IPasswordHasher<UserEntity>>());
}
_app.StartAsync().GetAwaiter().GetResult();
_client = new HttpClient { BaseAddress = new Uri(_app.Urls.First()) };
}
/// <summary>
/// 验证登录成功后,携带返回的 Bearer Token 能获取当前用户安全策略。
/// </summary>
[Fact]
public async Task GetSecurityPolicy_AfterLogin_ReturnsPolicy()
{
var loginResponse = await _client.PostAsJsonAsync("/api/auth/login", new LoginRequest
{
UserName = "admin",
Password = "123456"
});
Assert.Equal(HttpStatusCode.OK, loginResponse.StatusCode);
var loginPayload = await ReadDynamicApiResponseAsync<LoginResponse>(loginResponse);
Assert.True(loginPayload.Success);
Assert.False(string.IsNullOrWhiteSpace(loginPayload.Data?.AccessToken));
_client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", loginPayload.Data.AccessToken);
var policyResponse = await _client.GetAsync("/api/security/policy");
Assert.Equal(HttpStatusCode.OK, policyResponse.StatusCode);
var policyPayload = await ReadDynamicApiResponseAsync<SecurityPolicyDto>(policyResponse);
Assert.True(policyPayload.Success);
Assert.True(policyPayload.Data?.AllowPersist);
Assert.True(policyPayload.Data?.AllowSync);
}
/// <summary>
/// 释放测试 Host 与 SQLite 连接。
/// </summary>
public void Dispose()
{
_client.Dispose();
_app.StopAsync().GetAwaiter().GetResult();
_app.DisposeAsync().GetAwaiter().GetResult();
_connection.Dispose();
}
private static void SeedAdmin(TodoDbContext db, IPasswordHasher<UserEntity> hasher)
{
var user = new UserEntity
{
Id = Guid.NewGuid(),
UserName = "admin",
Role = "admin",
CreatedAtUtc = DateTime.UtcNow,
UpdatedAtUtc = DateTime.UtcNow
};
user.PasswordHash = hasher.HashPassword(user, "123456");
db.Users.Add(user);
db.SecurityPolicies.Add(new SecurityPolicyEntity
{
Id = Guid.NewGuid(),
UserId = user.Id,
AllowPersist = true,
AllowSync = true,
IsTrustedDeviceOnly = false
});
db.SaveChanges();
}
private static async Task<DynamicApiPayload<T>> ReadDynamicApiResponseAsync<T>(HttpResponseMessage response)
{
var payload = await response.Content.ReadFromJsonAsync<DynamicApiPayload<T>>(new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true
});
return payload ?? throw new InvalidOperationException("DynamicApi 响应为空。");
}
private sealed class DynamicApiPayload<T>
{
public bool Success { get; set; }
public T? Data { get; set; }
}
}
@@ -0,0 +1,82 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Hua.Todo.Application.Common;
using Hua.Todo.Application.Repositories;
using Hua.Todo.Application.Services.CloudSync.Services;
using Hua.Todo.HttpApi.AspNetCore.CloudSync.Services;
using Xunit;
namespace Hua.Todo.Host.Tests.CloudSync;
/// <summary>
/// 云同步核心服务 DI 注册集成测试。
/// 模拟 Host 的完整 DI 链路:AddApplicationServices + AddCloudSyncServer
/// 验证所有关键服务可被正确解析。
/// </summary>
public class CloudSyncEndpointRegistrationTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly ServiceProvider _provider;
public CloudSyncEndpointRegistrationTests()
{
// 使用共享缓存的 SQLite 内存数据库
_connection = new SqliteConnection("Data Source=CloudSyncRegTests;Mode=Memory;Cache=Shared");
_connection.Open();
var services = new ServiceCollection();
services.AddLogging();
// 模拟 Host 的完整 DI 链路
services.AddApplicationServices("Data Source=CloudSyncRegTests;Mode=Memory;Cache=Shared");
services.AddCloudSyncServer();
_provider = services.BuildServiceProvider();
// 确保数据库表已创建
using var scope = _provider.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<TodoDbContext>();
db.Database.EnsureCreated();
}
[Fact]
public void ResolveCloudProbeService_ShouldSucceed()
{
var service = _provider.GetRequiredService<CloudProbeService>();
Assert.NotNull(service);
}
[Fact]
public void ResolveCloudAuthService_ShouldSucceed()
{
var service = _provider.GetRequiredService<CloudAuthService>();
Assert.NotNull(service);
}
[Fact]
public void ResolveCloudTaskSyncService_ShouldSucceed()
{
var service = _provider.GetRequiredService<CloudTaskSyncService>();
Assert.NotNull(service);
}
[Fact]
public void ResolveSecurityPolicyService_ShouldSucceed()
{
var service = _provider.GetRequiredService<SecurityPolicyService>();
Assert.NotNull(service);
}
[Fact]
public void ResolveCloudAdminService_ShouldSucceed()
{
var service = _provider.GetRequiredService<CloudAdminService>();
Assert.NotNull(service);
}
public void Dispose()
{
_provider.Dispose();
_connection.Dispose();
}
}
@@ -0,0 +1,95 @@
using System.Net;
using System.Net.Http.Json;
using Hua.Todo.HttpApi.AspNetCore.CloudSync.Services;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Xunit;
namespace Hua.Todo.Host.Tests.CloudSync;
/// <summary>
/// CloudSyncProxyMiddleware 行为集成测试。
/// 验证云同步路径代理转发、无 URL 时返回 503。
/// settings 端点已迁移至 DynamicApi/api/cloudSyncProxySettings),不在本测试覆盖范围。
/// </summary>
public class CloudSyncProxyMiddlewareTests : IDisposable
{
private readonly WebApplication _app;
private readonly HttpClient _client;
public CloudSyncProxyMiddlewareTests()
{
var builder = WebApplication.CreateBuilder();
builder.WebHost.UseUrls("http://127.0.0.1:0");
builder.Services.AddCloudSyncProxy();
var app = builder.Build();
app.UseCloudSyncProxy();
_app = app;
_app.StartAsync().GetAwaiter().GetResult();
_client = new HttpClient { BaseAddress = new Uri(_app.Urls.First()) };
}
[Fact]
public async Task Probe_NoCloudSyncUrl_Returns503()
{
var response = await _client.PostAsJsonAsync("/api/cloud-sync/probe",
new { targetUrl = "http://localhost:5173" });
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
var content = await response.Content.ReadAsStringAsync();
Assert.Contains("cloud sync server URL not configured", content);
}
[Fact]
public async Task AuthLogin_NoCloudSyncUrl_Returns503()
{
var response = await _client.PostAsJsonAsync("/api/auth/login",
new { username = "admin", password = "123456" });
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
}
[Fact]
public async Task Tasks_NoCloudSyncUrl_Returns503()
{
var response = await _client.GetAsync("/api/tasks/");
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
}
[Fact]
public async Task Tasks_WithoutTrailingSlash_NoCloudSyncUrl_Returns503()
{
var response = await _client.PostAsJsonAsync("/api/tasks", new { });
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
}
[Fact]
public async Task SecurityPolicy_NoCloudSyncUrl_Returns503()
{
var response = await _client.GetAsync("/api/security/policy");
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
}
[Fact]
public async Task Sync_NoCloudSyncUrl_Returns503()
{
var response = await _client.PostAsJsonAsync("/api/sync/", new { });
Assert.Equal(HttpStatusCode.ServiceUnavailable, response.StatusCode);
}
public void Dispose()
{
_client.Dispose();
_app.StopAsync().GetAwaiter().GetResult();
_app.DisposeAsync().GetAwaiter().GetResult();
}
}
@@ -0,0 +1,137 @@
using System.Text.Json;
using Hua.Todo.Application.Services.CloudSync.Models;
using Hua.Todo.Core.Entities;
using Xunit;
namespace Hua.Todo.Host.Tests;
/// <summary>
/// CloudSync DTO 单元测试。
/// </summary>
public class CloudSyncDtoTests
{
/// <summary>
/// 测试 CloudTaskItem JSON 序列化使用 camelCase。
/// </summary>
[Fact]
public void CloudTaskItem_Serialization_UsesCamelCase()
{
// Arrange
var item = new CloudTaskItem
{
Id = Guid.Parse("12345678-1234-1234-1234-123456789012"),
Title = "Test",
Priority = TaskPriority.High,
IsCompleted = true,
CreationTime = new DateTime(2024, 1, 1, 0, 0, 0, DateTimeKind.Utc),
LastModificationTime = new DateTime(2024, 1, 2, 0, 0, 0, DateTimeKind.Utc),
IsDeleted = false
};
// Act
var json = JsonSerializer.Serialize(item);
var deserialized = JsonSerializer.Deserialize<Dictionary<string, object>>(json);
// Assert
Assert.NotNull(deserialized);
Assert.True(deserialized.ContainsKey("id"));
Assert.True(deserialized.ContainsKey("title"));
Assert.True(deserialized.ContainsKey("priority"));
Assert.True(deserialized.ContainsKey("isCompleted"));
Assert.True(deserialized.ContainsKey("creationTime"));
Assert.True(deserialized.ContainsKey("lastModificationTime"));
Assert.True(deserialized.ContainsKey("isDeleted"));
}
/// <summary>
/// 测试 CloudTaskUpsert JSON 反序列化。
/// </summary>
[Fact]
public void CloudTaskUpsert_Deserialization_FromCamelCase()
{
// Arrange
var json = @"{""id"":""12345678-1234-1234-1234-123456789012"",""title"":""Test Task"",""priority"":2,""isCompleted"":true,""lastModificationTime"":""2024-01-02T00:00:00Z""}";
// Act
var upsert = JsonSerializer.Deserialize<CloudTaskUpsert>(json);
// Assert
Assert.NotNull(upsert);
Assert.Equal(Guid.Parse("12345678-1234-1234-1234-123456789012"), upsert.Id);
Assert.Equal("Test Task", upsert.Title);
Assert.Equal(TaskPriority.High, upsert.Priority);
Assert.True(upsert.IsCompleted);
}
/// <summary>
/// 测试 SyncRequest JSON 反序列化。
/// </summary>
[Fact]
public void SyncRequest_Deserialization()
{
// Arrange
var json = @"{
""upserts"": [
{""id"":""12345678-1234-1234-1234-123456789012"",""title"":""Task 1""},
{""title"":""Task 2""}
],
""deletes"": [""87654321-4321-4321-4321-210987654321""]
}";
// Act
var request = JsonSerializer.Deserialize<SyncRequest>(json);
// Assert
Assert.NotNull(request);
Assert.Equal(2, request.Upserts.Count);
Assert.Single(request.Deletes);
}
/// <summary>
/// 测试 SyncResponse JSON 序列化。
/// </summary>
[Fact]
public void SyncResponse_Serialization()
{
// Arrange
var response = new SyncResponse
{
ServerTimeUtc = new DateTime(2024, 1, 1, 12, 0, 0, DateTimeKind.Utc),
Tasks = new List<CloudTaskItem>
{
new CloudTaskItem
{
Id = Guid.NewGuid(),
Title = "Task 1",
Priority = TaskPriority.Medium
}
}
};
// Act
var json = JsonSerializer.Serialize(response);
var deserialized = JsonSerializer.Deserialize<Dictionary<string, object>>(json);
// Assert
Assert.NotNull(deserialized);
Assert.True(deserialized.ContainsKey("serverTimeUtc"));
Assert.True(deserialized.ContainsKey("tasks"));
}
/// <summary>
/// 测试 Guid 类型的 JSON 序列化。
/// </summary>
[Fact]
public void Guid_Serialization_IsString()
{
// Arrange
var guid = Guid.NewGuid();
var item = new CloudTaskItem { Id = guid };
// Act
var json = JsonSerializer.Serialize(item);
// Assert
Assert.Contains(guid.ToString(), json);
}
}
@@ -0,0 +1,421 @@
using Hua.Todo.Application.Repositories;
using Hua.Todo.Application.Services.CloudSync.Models;
using Hua.Todo.Application.Services.CloudSync.Services;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Hua.Todo.Core.Entities;
using Xunit;
namespace Hua.Todo.Host.Tests;
/// <summary>
/// CloudTaskSyncService SQLite 集成测试(验证 UNIQUE 约束不会在生产环境中触发)。
/// </summary>
public class CloudTaskSyncServiceSqliteTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly TodoDbContext _dbContext;
private readonly CloudTaskSyncService _service;
private readonly Guid _testUserId;
public CloudTaskSyncServiceSqliteTests()
{
_testUserId = Guid.NewGuid();
// 使用共享缓存的 SQLite 内存数据库
_connection = new SqliteConnection("Data Source=CloudTaskSyncTests;Mode=Memory;Cache=Shared");
_connection.Open();
var options = new DbContextOptionsBuilder<TodoDbContext>()
.UseSqlite(_connection)
.Options;
_dbContext = new TodoDbContext(options);
_dbContext.Database.EnsureCreated();
// 创建测试用户(满足 UserId 外键约束)
_dbContext.Users.Add(new UserEntity
{
Id = _testUserId,
UserName = "test_user",
PasswordHash = "hash",
PasswordSalt = "salt",
Role = "User"
});
_dbContext.SaveChanges();
_service = new CloudTaskSyncService(_dbContext, NullLogger<CloudTaskSyncService>.Instance);
}
public void Dispose()
{
_dbContext.Dispose();
_connection.Dispose();
}
/// <summary>
/// 复现用户报告的 Bug:同一批次中同步父子任务(均有已知 IdlastModificationTime 为 null)。
/// 期望:首次同步成功创建两个任务。
/// </summary>
[Fact]
public async Task SyncAsync_ParentChildWithNullTime_FirstSync_Succeeds()
{
var parentId = Guid.NewGuid();
var childId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = childId,
Title = "子任务",
Priority = TaskPriority.Medium,
Code = "2",
ParentTaskId = parentId,
LastModificationTime = null
},
new CloudTaskUpsert
{
Id = parentId,
Title = "父任务",
Priority = TaskPriority.High,
Code = "1",
ParentTaskId = null,
LastModificationTime = null
}
}
};
var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, response.Tasks.Count);
var parent = response.Tasks.First(t => t.Title == "父任务");
Assert.Null(parent.ParentTaskId);
var child = response.Tasks.First(t => t.Title == "子任务");
Assert.Equal(parentId, child.ParentTaskId);
}
/// <summary>
/// 复现 Bug 场景:首次同步成功后再用相同数据重新同步(模拟客户端重试)。
/// lastModificationTime 为 null 时 LWW 应跳过更新,不应抛出 UNIQUE 约束。
/// </summary>
[Fact]
public async Task SyncAsync_ParentChildWithNullTime_Resync_NoUniqueViolation()
{
var parentId = Guid.NewGuid();
var childId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = childId, Title = "子任务", Priority = TaskPriority.Medium,
Code = "2", ParentTaskId = parentId, LastModificationTime = null
},
new CloudTaskUpsert
{
Id = parentId, Title = "父任务", Priority = TaskPriority.High,
Code = "1", ParentTaskId = null, LastModificationTime = null
}
}
};
// 首次同步
var firstResponse = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, firstResponse.Tasks.Count);
// 重新同步(同一 DbContext,模拟客户端重试)
var resyncResponse = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, resyncResponse.Tasks.Count);
}
/// <summary>
/// 测试多次重试同步不引发 UNIQUE 约束。
/// </summary>
[Fact]
public async Task SyncAsync_ParentChildWithNullTime_MultipleResyncs_NoUniqueViolation()
{
var parentId = Guid.NewGuid();
var childId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = childId, Title = "子任务", Priority = TaskPriority.Medium,
Code = "2", ParentTaskId = parentId, LastModificationTime = null
},
new CloudTaskUpsert
{
Id = parentId, Title = "父任务", Priority = TaskPriority.High,
Code = "1", ParentTaskId = null, LastModificationTime = null
}
}
};
for (int i = 0; i < 5; i++)
{
var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, response.Tasks.Count);
}
}
/// <summary>
/// 测试已有 DbContext 跟踪实体的场景下同步不会引发 UNIQUE 冲突。
/// 模拟场景:任务已通过其他 API 创建并仍被跟踪,然后通过 CloudSync 同步同一数据。
/// </summary>
[Fact]
public async Task SyncAsync_TaskExistsThenSynced_NoUniqueViolation()
{
var taskId = Guid.NewGuid();
var existingTask = new TaskEntity
{
Id = taskId,
UserId = _testUserId,
Title = "已有任务",
Priority = TaskPriority.Medium,
CreationTime = DateTime.UtcNow,
CreatorId = _testUserId,
LastModificationTime = DateTime.UtcNow,
LastModifierId = _testUserId
};
_dbContext.Tasks.Add(existingTask);
await _dbContext.SaveChangesAsync();
var syncRequest = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "已有任务",
LastModificationTime = null
}
}
};
var response = await _service.SyncAsync(_testUserId, syncRequest, CancellationToken.None);
Assert.Single(response.Tasks);
Assert.Equal("已有任务", response.Tasks.First().Title);
}
/// <summary>
/// 复现用户 Bug 的核心场景:使用独立 DbContext 模拟跨请求重试。
/// Context A 创建任务并提交,Context B 再次同步相同数据时不应触发 UNIQUE 约束。
/// </summary>
[Fact]
public async Task SyncAsync_CrossContextResync_NoUniqueViolation()
{
var parentId = Guid.NewGuid();
var childId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = childId, Title = "子任务", Priority = TaskPriority.Medium,
Code = "2", ParentTaskId = parentId, LastModificationTime = null
},
new CloudTaskUpsert
{
Id = parentId, Title = "父任务", Priority = TaskPriority.High,
Code = "1", ParentTaskId = null, LastModificationTime = null
}
}
};
// Context A:首次同步
var firstResponse = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, firstResponse.Tasks.Count);
// Context B:使用全新 DbContext 重新同步(模拟另一个请求)
var options = new DbContextOptionsBuilder<TodoDbContext>()
.UseSqlite(_connection)
.Options;
using var dbContextB = new TodoDbContext(options);
var serviceB = new CloudTaskSyncService(dbContextB, NullLogger<CloudTaskSyncService>.Instance);
var resyncResponse = await serviceB.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, resyncResponse.Tasks.Count);
}
/// <summary>
/// 使用报错场景中的精确 UUID 复现 Bug:
/// 父子任务在同一批次中同步,lastModificationTime 均为 null。
/// </summary>
[Fact]
public async Task SyncAsync_ExactBugReportUuids_NoUniqueViolation()
{
// 使用报错日志中的精确 UUID
var parentId = Guid.Parse("6afeea6f-296f-4da1-a1e1-279e818f932c");
var childId = Guid.Parse("412290d0-01b7-4348-af2b-a6abb55dd580");
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = childId,
Title = "测试",
Priority = (TaskPriority)1,
IsCompleted = false,
Code = "2",
ParentTaskId = parentId,
LastModificationTime = null
},
new CloudTaskUpsert
{
Id = parentId,
Title = "测试",
Priority = (TaskPriority)1,
IsCompleted = false,
Code = "1",
ParentTaskId = null,
LastModificationTime = null
}
}
};
// 首次同步
var response1 = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, response1.Tasks.Count);
// 使用全新 DbContext 重新同步(模拟跨请求重试)
var options = new DbContextOptionsBuilder<TodoDbContext>()
.UseSqlite(_connection)
.Options;
using var dbContextB = new TodoDbContext(options);
var serviceB = new CloudTaskSyncService(dbContextB, NullLogger<CloudTaskSyncService>.Instance);
// 重试同步 - 不应抛出 UNIQUE 约束
var response2 = await serviceB.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, response2.Tasks.Count);
}
/// <summary>
/// 测试同一次同步请求中,父任务已存在于 DB 但 lastModificationTime 为 null 的重同步场景。
/// </summary>
[Fact]
public async Task SyncAsync_ExistingTaskWithNullTime_NoUniqueViolation()
{
// 使用精确 UUID
var parentId = Guid.Parse("6afeea6f-296f-4da1-a1e1-279e818f932c");
var childId = Guid.Parse("412290d0-01b7-4348-af2b-a6abb55dd580");
// 先创建一个已存在的父任务(模拟前置同步已完成)
var existingParent = new TaskEntity
{
Id = parentId,
UserId = _testUserId,
Title = "测试",
Priority = (TaskPriority)1,
IsCompleted = false,
Code = "1",
ParentTaskId = null,
CreationTime = DateTime.UtcNow,
CreatorId = _testUserId,
LastModificationTime = DateTime.UtcNow,
LastModifierId = _testUserId
};
_dbContext.Tasks.Add(existingParent);
await _dbContext.SaveChangesAsync();
// 同步请求(lastModificationTime 为 null,应被 LWW 跳过)
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = parentId,
Title = "测试",
Priority = (TaskPriority)1,
IsCompleted = false,
Code = "1",
ParentTaskId = null,
LastModificationTime = null // 🔑 关键:null 时间戳
}
}
};
// 不应抛出 UNIQUE 约束
var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Single(response.Tasks);
}
/// <summary>
/// 验证跨 UserId 同步相同 Id 不再触发 UNIQUE 约束。
/// 任务已存在于 DB(通过其他 UserId 创建),用不同 UserId 同步相同 Id,
/// 应被 ProcessUpsertAsync 的 DB 级别二次查重发现并安全跳过。
/// </summary>
[Fact]
public async Task SyncAsync_TaskExistsWithDifferentUserId_HandlesGracefully()
{
// 创建另一个用户的 DbContext
var otherUserId = Guid.NewGuid();
_dbContext.Users.Add(new UserEntity
{
Id = otherUserId,
UserName = "other_user",
PasswordHash = "hash",
PasswordSalt = "salt",
Role = "User"
});
await _dbContext.SaveChangesAsync();
// 使用报错 UUID 创建任务,但属于 otherUserId
var parentId = Guid.Parse("6afeea6f-296f-4da1-a1e1-279e818f932c");
_dbContext.Tasks.Add(new TaskEntity
{
Id = parentId,
UserId = otherUserId,
Title = "other user task",
Priority = (TaskPriority)1,
CreationTime = DateTime.UtcNow,
CreatorId = otherUserId,
LastModificationTime = DateTime.UtcNow,
LastModifierId = otherUserId
});
await _dbContext.SaveChangesAsync();
// 使用独立 DbContext 同步相同 Id(模拟跨请求)
var options = new DbContextOptionsBuilder<TodoDbContext>()
.UseSqlite(_connection)
.Options;
using var dbContextB = new TodoDbContext(options);
var serviceB = new CloudTaskSyncService(dbContextB, NullLogger<CloudTaskSyncService>.Instance);
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = parentId,
Title = "测试",
Priority = (TaskPriority)1,
IsCompleted = false,
Code = "1",
ParentTaskId = null,
LastModificationTime = null
}
}
};
// 应正常完成(不抛出 DbUpdateException),DB 级别二次查重发现已存在实体并跳过
// 注意:response.Tasks 为空,因为该任务属于 otherUserId,不属于 _testUserId
var response = await serviceB.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Empty(response.Tasks);
}
}
@@ -0,0 +1,679 @@
using Hua.Todo.Application.Repositories;
using Hua.Todo.Application.Services.CloudSync.Models;
using Hua.Todo.Application.Services.CloudSync.Services;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Microsoft.Extensions.Logging.Abstractions;
using Hua.Todo.Core.Entities;
using Xunit;
namespace Hua.Todo.Host.Tests;
/// <summary>
/// CloudTaskSyncService 单元测试。
/// </summary>
public class CloudTaskSyncServiceTests : IDisposable
{
private readonly TodoDbContext _dbContext;
private readonly CloudTaskSyncService _service;
private readonly Guid _testUserId = Guid.NewGuid();
public CloudTaskSyncServiceTests()
{
var options = new DbContextOptionsBuilder<TodoDbContext>()
.UseInMemoryDatabase(databaseName: Guid.NewGuid().ToString())
.ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning))
.Options;
_dbContext = new TodoDbContext(options);
_service = new CloudTaskSyncService(_dbContext, NullLogger<CloudTaskSyncService>.Instance);
}
public void Dispose()
{
_dbContext.Dispose();
}
/// <summary>
/// 测试创建新任务。
/// </summary>
[Fact]
public async Task SyncAsync_NewTask_CreatesTask()
{
// Arrange
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = Guid.NewGuid(),
Title = "Test Task",
Priority = TaskPriority.High,
IsCompleted = false,
LastModificationTime = DateTime.UtcNow
}
}
};
// Act
var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
// Assert
Assert.Single(response.Tasks);
var task = response.Tasks.First();
Assert.Equal("Test Task", task.Title);
Assert.Equal(TaskPriority.High, task.Priority);
Assert.False(task.IsCompleted);
Assert.NotEqual(Guid.Empty, task.Id);
}
/// <summary>
/// 测试更新已有任务。
/// </summary>
[Fact]
public async Task SyncAsync_ExistingTask_UpdatesTask()
{
// Arrange
var taskId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "Updated Task",
Priority = TaskPriority.Low,
IsCompleted = true,
LastModificationTime = DateTime.UtcNow
}
}
};
// Act
await _service.SyncAsync(_testUserId, request, CancellationToken.None);
var response = await _service.GetTasksAsync(_testUserId, CancellationToken.None);
// Assert
Assert.Single(response);
var task = response.First();
Assert.Equal(taskId, task.Id);
Assert.Equal("Updated Task", task.Title);
Assert.Equal(TaskPriority.Low, task.Priority);
Assert.True(task.IsCompleted);
}
/// <summary>
/// 测试删除任务(Tombstone)。
/// </summary>
[Fact]
public async Task SyncAsync_DeleteTask_MarksAsDeleted()
{
// Arrange
var taskId = Guid.NewGuid();
var createRequest = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "Task to Delete",
LastModificationTime = DateTime.UtcNow
}
}
};
var createResponse = await _service.SyncAsync(_testUserId, createRequest, CancellationToken.None);
Assert.Single(createResponse.Tasks);
var deleteRequest = new SyncRequest
{
Deletes = new List<Guid> { taskId }
};
// Act
var deleteResponse = await _service.SyncAsync(_testUserId, deleteRequest, CancellationToken.None);
// Assert - 直接检查返回的任务
var deletedTask = deleteResponse.Tasks.FirstOrDefault(t => t.Id == taskId);
Assert.NotNull(deletedTask);
Assert.True(deletedTask.IsDeleted);
Assert.NotNull(deletedTask.DeletionTime);
}
/// <summary>
/// 测试 LWW 冲突解决:客户端更新时接受较新版本。
/// </summary>
[Fact]
public async Task SyncAsync_LWW_ClientNewer_AcceptsClientVersion()
{
// Arrange
var taskId = Guid.NewGuid();
var olderTime = DateTime.UtcNow.AddMinutes(-10);
var newerTime = DateTime.UtcNow;
// 先创建旧版本
var createRequest = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "Original",
LastModificationTime = olderTime
}
}
};
await _service.SyncAsync(_testUserId, createRequest, CancellationToken.None);
// 再提交新版本
var updateRequest = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "Updated by Client",
LastModificationTime = newerTime
}
}
};
// Act
await _service.SyncAsync(_testUserId, updateRequest, CancellationToken.None);
var response = await _service.GetTasksAsync(_testUserId, CancellationToken.None);
// Assert
Assert.Single(response);
var task = response.First();
Assert.Equal("Updated by Client", task.Title);
}
/// <summary>
/// 测试 LWW 冲突解决:服务端更新时拒绝旧版本。
/// </summary>
[Fact]
public async Task SyncAsync_LWW_ServerNewer_KeepsServerVersion()
{
// Arrange
var taskId = Guid.NewGuid();
var serverTime = DateTime.UtcNow;
var clientOldTime = DateTime.UtcNow.AddMinutes(-10);
// 先创建服务端版本
var createRequest = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "Server Version",
LastModificationTime = serverTime
}
}
};
await _service.SyncAsync(_testUserId, createRequest, CancellationToken.None);
// 再提交客户端旧版本
var updateRequest = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "Client Old Version",
LastModificationTime = clientOldTime
}
}
};
// Act
await _service.SyncAsync(_testUserId, updateRequest, CancellationToken.None);
var response = await _service.GetTasksAsync(_testUserId, CancellationToken.None);
// Assert
Assert.Single(response);
var task = response.First();
Assert.Equal("Server Version", task.Title);
}
/// <summary>
/// 测试获取任务全量(含 Tombstone)。
/// </summary>
[Fact]
public async Task GetTasksAsync_ReturnsAllTasksIncludingDeleted()
{
// Arrange
var taskId = Guid.NewGuid();
var createRequest = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "Task",
LastModificationTime = DateTime.UtcNow
}
}
};
await _service.SyncAsync(_testUserId, createRequest, CancellationToken.None);
var deleteRequest = new SyncRequest
{
Deletes = new List<Guid> { taskId }
};
await _service.SyncAsync(_testUserId, deleteRequest, CancellationToken.None);
// Act
var response = await _service.GetTasksAsync(_testUserId, CancellationToken.None);
// Assert
Assert.Single(response); // GetTasksAsync returns all including deleted
Assert.True(response.First().IsDeleted);
}
/// <summary>
/// 测试父子任务关系。
/// </summary>
[Fact]
public async Task SyncAsync_ParentChildTask_CreatesRelationship()
{
// Arrange
var parentId = Guid.NewGuid();
var childId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = parentId,
Title = "Parent Task",
LastModificationTime = DateTime.UtcNow
},
new CloudTaskUpsert
{
Id = childId,
Title = "Child Task",
ParentTaskId = parentId,
LastModificationTime = DateTime.UtcNow
}
}
};
// Act
await _service.SyncAsync(_testUserId, request, CancellationToken.None);
var response = await _service.GetTasksAsync(_testUserId, CancellationToken.None);
// Assert
Assert.Equal(2, response.Count);
var child = response.First(t => t.Title == "Child Task");
Assert.Equal(parentId, child.ParentTaskId);
}
/// <summary>
/// 测试软删除时递归删除子任务。
/// </summary>
[Fact]
public async Task SyncAsync_DeleteParentTask_RecursiveDeletesChildren()
{
// Arrange
var parentId = Guid.NewGuid();
var childId = Guid.NewGuid();
var createRequest = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = parentId,
Title = "Parent",
LastModificationTime = DateTime.UtcNow
},
new CloudTaskUpsert
{
Id = childId,
Title = "Child",
ParentTaskId = parentId,
LastModificationTime = DateTime.UtcNow
}
}
};
await _service.SyncAsync(_testUserId, createRequest, CancellationToken.None);
var deleteRequest = new SyncRequest
{
Deletes = new List<Guid> { parentId }
};
// Act
await _service.SyncAsync(_testUserId, deleteRequest, CancellationToken.None);
var response = await _service.GetTasksAsync(_testUserId, CancellationToken.None);
// Assert
Assert.Equal(2, response.Count);
Assert.All(response, t => Assert.True(t.IsDeleted));
}
/// <summary>
/// 测试 Code 字段在创建和返回时正确映射。
/// </summary>
[Fact]
public async Task SyncAsync_Code_IsStoredAndReturned()
{
// Arrange
var taskId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "Task with Code",
Code = "5",
LastModificationTime = DateTime.UtcNow
}
}
};
// Act
var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
// Assert
Assert.Single(response.Tasks);
Assert.Equal("5", response.Tasks.First().Code);
}
/// <summary>
/// 测试 Code 字段在更新时正确覆盖。
/// </summary>
[Fact]
public async Task SyncAsync_Code_IsUpdated()
{
// Arrange
var taskId = Guid.NewGuid();
var createRequest = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "Original",
Code = "1",
LastModificationTime = DateTime.UtcNow
}
}
};
await _service.SyncAsync(_testUserId, createRequest, CancellationToken.None);
var updateRequest = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "Updated",
Code = "99",
LastModificationTime = DateTime.UtcNow.AddMinutes(1)
}
}
};
// Act
var response = await _service.SyncAsync(_testUserId, updateRequest, CancellationToken.None);
// Assert
Assert.Single(response.Tasks);
Assert.Equal("99", response.Tasks.First().Code);
}
/// <summary>
/// 测试新任务 Code 为空字符串时的处理。
/// </summary>
[Fact]
public async Task SyncAsync_Code_EmptyString_StoredAsEmpty()
{
// Arrange
var taskId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "No Code",
Code = string.Empty,
LastModificationTime = DateTime.UtcNow
}
}
};
// Act
var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
// Assert
Assert.Single(response.Tasks);
Assert.Equal(string.Empty, response.Tasks.First().Code);
}
/// <summary>
/// 测试同一批次中包含子任务引用新创建的父任务(父子任务 Id 均已知)。
/// 验证第二遍处理子任务时能正确找到已创建父任务的 Id。
/// </summary>
[Fact]
public async Task SyncAsync_ParentChild_SameBatch_ChildFindsNewParent()
{
// Arrange
var parentId = Guid.NewGuid();
var childId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = parentId,
Title = "Parent",
LastModificationTime = DateTime.UtcNow
},
new CloudTaskUpsert
{
Id = childId,
Title = "Child",
ParentTaskId = parentId,
LastModificationTime = DateTime.UtcNow
}
}
};
// Act
await _service.SyncAsync(_testUserId, request, CancellationToken.None);
var response = await _service.GetTasksAsync(_testUserId, CancellationToken.None);
// Assert
Assert.Equal(2, response.Count);
var child = response.First(t => t.Title == "Child");
Assert.Equal(parentId, child.ParentTaskId);
var parent = response.First(t => t.Title == "Parent");
Assert.Null(parent.ParentTaskId);
}
/// <summary>
/// 测试去重:同一批次中相同 Id 出现多次,只保留最后一条。
/// </summary>
[Fact]
public async Task SyncAsync_DuplicateId_LastWins()
{
// Arrange
var taskId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "First Version",
Code = "1",
LastModificationTime = DateTime.UtcNow.AddMinutes(-5)
},
new CloudTaskUpsert
{
Id = taskId,
Title = "Last Version",
Code = "2",
LastModificationTime = DateTime.UtcNow
}
}
};
// Act
var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
// Assert
Assert.Single(response.Tasks);
Assert.Equal("Last Version", response.Tasks.First().Title);
Assert.Equal("2", response.Tasks.First().Code);
}
/// <summary>
/// 测试更新已有任务时不会触发 UNIQUE 约束冲突。
/// </summary>
[Fact]
public async Task SyncAsync_UpdateExistingTask_NoUniqueConstraintViolation()
{
// Arrange - 先创建一个任务
var taskId = Guid.NewGuid();
var createRequest = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "Original",
Priority = TaskPriority.High,
IsCompleted = false,
LastModificationTime = DateTime.UtcNow.AddMinutes(-5)
}
}
};
await _service.SyncAsync(_testUserId, createRequest, CancellationToken.None);
// Act - 用相同的 Id 再次同步(更新),模拟客户端重发
var updateRequest = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "Updated",
Priority = TaskPriority.Low,
IsCompleted = true,
LastModificationTime = DateTime.UtcNow // 较新时间
}
}
};
// Assert - 不应抛出 DbUpdateException
var response = await _service.SyncAsync(_testUserId, updateRequest, CancellationToken.None);
Assert.Single(response.Tasks);
Assert.Equal("Updated", response.Tasks.First().Title);
}
/// <summary>
/// 测试同一批次中父子任务(均有 Id),先处理父再处理子时无 UNIQUE 冲突。
/// </summary>
[Fact]
public async Task SyncAsync_ParentChildSameBatch_NoUniqueConstraintViolation()
{
// Arrange - 父子任务均在同一批次中,模拟客户端新建父子任务后同步
var parentId = Guid.NewGuid();
var childId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = parentId,
Title = "Parent Task",
Priority = TaskPriority.High,
IsCompleted = false,
Code = "1",
ParentTaskId = null,
LastModificationTime = null // 与实际错误场景一致
},
new CloudTaskUpsert
{
Id = childId,
Title = "Child Task",
Priority = TaskPriority.Medium,
IsCompleted = false,
Code = "2",
ParentTaskId = parentId,
LastModificationTime = null
}
}
};
// Act & Assert - 不应抛出 UNIQUE constraint failed
var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, response.Tasks.Count);
}
/// <summary>
/// 测试 Title 为空或仅空白的 upsert 被过滤掉。
/// </summary>
[Fact]
public async Task SyncAsync_EmptyTitle_FilteredOut()
{
// Arrange
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = Guid.NewGuid(),
Title = " ",
LastModificationTime = DateTime.UtcNow
},
new CloudTaskUpsert
{
Id = Guid.NewGuid(),
Title = "",
LastModificationTime = DateTime.UtcNow
},
new CloudTaskUpsert
{
Id = Guid.NewGuid(),
Title = "Valid Task",
LastModificationTime = DateTime.UtcNow
}
}
};
// Act
var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
// Assert
Assert.Single(response.Tasks);
Assert.Equal("Valid Task", response.Tasks.First().Title);
}
}
@@ -0,0 +1,290 @@
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Xunit;
using System.Data.Common;
using Hua.Todo.Application.Repositories;
namespace Hua.Todo.Host.Tests;
/// <summary>
/// 数据库迁移集成测试,验证迁移链的完整性与幂等性。
/// 覆盖 Maui/Avalonia 中 InitializeDatabase 的 Migrate → EnsureCreated 回退链路。
/// </summary>
public class DatabaseMigrationTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly DbContextOptions<TodoDbContext> _options;
public DatabaseMigrationTests()
{
// 使用独立的内存数据库(每次测试独立的连接)
_connection = new SqliteConnection("Data Source=:memory:");
_connection.Open();
_options = new DbContextOptionsBuilder<TodoDbContext>()
.UseSqlite(_connection)
.Options;
}
public void Dispose()
{
_connection.Dispose();
}
/// <summary>
/// 创建新的 DbContext 实例。
/// </summary>
private TodoDbContext CreateContext()
{
return new TodoDbContext(_options);
}
/// <summary>
/// 验证所有迁移可以从头开始成功应用(Migrate 从零到当前模型)。
/// </summary>
[Fact]
public void Migrate_FromScratch_AllMigrationsApplied()
{
// Arrange
using var context = CreateContext();
// Act: 执行全部迁移
context.Database.Migrate();
// Assert: 验证所有核心表已创建
var tables = GetTableNames(context);
Assert.Contains("T_Tasks", tables);
Assert.Contains("Attachments", tables);
Assert.Contains("Users", tables);
Assert.Contains("UserSessions", tables);
Assert.Contains("SecurityPolicies", tables);
Assert.Contains("AuditLogs", tables);
}
/// <summary>
/// 验证 Migrate 是幂等的:第二次调用 Migrate 不会报错。
/// </summary>
[Fact]
public void Migrate_Idempotent_SecondCallDoesNotThrow()
{
// Arrange
using var context = CreateContext();
context.Database.Migrate();
// Act & Assert: 第二次 Migrate 不应抛出异常
var exception = Record.Exception(() => context.Database.Migrate());
Assert.Null(exception);
}
/// <summary>
/// 验证 T_Tasks 表包含所有期望的列(ABP 审计字段 + 业务字段 + v1.3.0 新增字段)。
/// </summary>
[Fact]
public void T_Tasks_HasAllExpectedColumns()
{
// Arrange
using var context = CreateContext();
context.Database.Migrate();
// Act
var columns = GetColumnNames(context, "T_Tasks");
// Assert: ABP 审计字段
Assert.Contains("Id", columns);
Assert.Contains("ExtraProperties", columns);
Assert.Contains("ConcurrencyStamp", columns);
Assert.Contains("CreationTime", columns);
Assert.Contains("CreatorId", columns);
Assert.Contains("LastModificationTime", columns);
Assert.Contains("LastModifierId", columns);
Assert.Contains("IsDeleted", columns);
Assert.Contains("DeletionTime", columns);
Assert.Contains("DeleterId", columns);
// Assert: 业务字段
Assert.Contains("UserId", columns);
Assert.Contains("Title", columns);
Assert.Contains("Priority", columns);
Assert.Contains("IsCompleted", columns);
Assert.Contains("Code", columns);
Assert.Contains("ParentTaskId", columns);
// Assert: v1.3.0 新增字段
Assert.Contains("TaskType", columns);
Assert.Contains("MeetingNotes", columns);
Assert.Contains("AudioDuration", columns);
Assert.Contains("Description", columns);
}
/// <summary>
/// 验证 Attachments 表存在且包含期望的列。
/// </summary>
[Fact]
public void Attachments_HasAllExpectedColumns()
{
// Arrange
using var context = CreateContext();
context.Database.Migrate();
// Act
var columns = GetColumnNames(context, "Attachments");
// Assert
Assert.Contains("Id", columns);
Assert.Contains("FileName", columns);
Assert.Contains("FilePath", columns);
Assert.Contains("ContentType", columns);
Assert.Contains("FileSize", columns);
Assert.Contains("AttachmentType", columns);
Assert.Contains("CreatedAt", columns);
Assert.Contains("TaskId", columns);
}
/// <summary>
/// 验证 EnsureCreated 可以独立创建完整 schema(不依赖迁移历史)。
/// 模拟 MauiProgram.InitializeDatabase 的 fallback 路径。
/// </summary>
[Fact]
public void EnsureCreated_FromScratch_CreatesAllTables()
{
// Arrange
using var context = CreateContext();
// Act: 使用 EnsureCreated 而非 Migrate
context.Database.EnsureCreated();
// Assert: 所有核心表存在
var tables = GetTableNames(context);
Assert.Contains("T_Tasks", tables);
Assert.Contains("Attachments", tables);
Assert.Contains("Users", tables);
Assert.Contains("UserSessions", tables);
Assert.Contains("SecurityPolicies", tables);
Assert.Contains("AuditLogs", tables);
}
/// <summary>
/// 验证 Migrate 后所有核心表(包括 Attachments)都存在。
/// </summary>
[Fact]
public void Migrate_AllTablesExist_IncludingAttachments()
{
// Arrange
using var context = CreateContext();
// Act: 执行全部迁移
context.Database.Migrate();
// Assert: 所有核心表已创建
var tables = GetTableNames(context);
Assert.Contains("T_Tasks", tables);
Assert.Contains("Attachments", tables);
Assert.Contains("Users", tables);
Assert.Contains("UserSessions", tables);
Assert.Contains("SecurityPolicies", tables);
Assert.Contains("AuditLogs", tables);
}
/// <summary>
/// 验证迁移后可以正常读写 T_Tasks 表。
/// </summary>
[Fact]
public void Migrate_CanInsertAndQueryTask()
{
// Arrange
using var context = CreateContext();
context.Database.Migrate();
// 创建测试用户
var userId = Guid.NewGuid();
context.Users.Add(new Core.Entities.UserEntity
{
Id = userId,
UserName = "test",
PasswordHash = "hash",
PasswordSalt = "salt",
Role = "User"
});
context.SaveChanges();
// Act: 插入任务
var taskId = Guid.NewGuid();
context.Tasks.Add(new Core.Entities.TaskEntity
{
Id = taskId,
UserId = userId,
Title = "测试任务",
Priority = Core.Entities.TaskPriority.High,
Code = "T001",
Description = "测试描述",
TaskType = Core.Entities.TaskType.Normal,
CreationTime = DateTime.UtcNow,
CreatorId = userId
});
context.SaveChanges();
// Assert: 查询验证
var task = context.Tasks.Find(taskId);
Assert.NotNull(task);
Assert.Equal("测试任务", task!.Title);
Assert.Equal("测试描述", task.Description);
Assert.Equal(Core.Entities.TaskType.Normal, task.TaskType);
}
/// <summary>
/// 验证迁移历史表包含所有预期迁移。
/// </summary>
[Fact]
public void Migrate_RecordsAllMigrationsInHistory()
{
// Arrange
using var context = CreateContext();
// Act
context.Database.Migrate();
// Assert
var history = context.GetService<IHistoryRepository>();
var appliedMigrations = history.GetAppliedMigrations().Select(m => m.MigrationId).ToList();
Assert.NotEmpty(appliedMigrations);
// 迁移已合并为单一 InitialCreate,仅验证该迁移已记录
Assert.Contains(appliedMigrations, m => m.Contains("InitialCreate"));
}
// ========== 辅助方法 ==========
/// <summary>
/// 获取数据库中所有表名。
/// </summary>
private static List<string> GetTableNames(TodoDbContext context)
{
return context.Database.SqlQueryRaw<string>(
"SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%' AND name NOT LIKE '__EF%' ORDER BY name"
).ToList();
}
/// <summary>
/// 获取指定表的列名列表。
/// 使用原始 ADO.NET 查询 PRAGMA table_info 以避免 EF Core 列映射问题。
/// </summary>
private static List<string> GetColumnNames(TodoDbContext context, string tableName)
{
var columns = new List<string>();
var connection = context.Database.GetDbConnection();
// 确保连接已打开(内存数据库可能已关闭)
if (connection.State != System.Data.ConnectionState.Open)
connection.Open();
using var command = connection.CreateCommand();
command.CommandText = $"PRAGMA table_info('{tableName}')";
using var reader = command.ExecuteReader();
while (reader.Read())
{
// PRAGMA table_info 返回: cid(0), name(1), type(2), notnull(3), dflt_value(4), pk(5)
columns.Add(reader.GetString(1));
}
return columns;
}
}
@@ -0,0 +1,492 @@
using System.ComponentModel;
using System.Reflection;
using System.Text.Json;
using Microsoft.Extensions.DependencyInjection;
using ModelContextProtocol.Protocol;
using ModelContextProtocol.Server;
using Hua.Todo.HttpApi.AspNetCore.Mcp;
using Hua.Todo.HttpApi.Interfaces;
using Hua.Todo.Application.Models;
using Hua.Todo.Application.Services.Interfaces;
using Xunit;
namespace Hua.Todo.Host.Tests;
/// <summary>
/// DynamicMcpToolExtensions 单元测试。
/// 重点验证命名推导、描述生成、行为注解等纯逻辑,以及动态工具注册。
/// </summary>
public class DynamicMcpToolExtensionsTests
{
#region
/// <summary>
/// DeriveServiceNameITaskService → task
/// </summary>
[Fact]
public void DeriveServiceName_ITaskService_ReturnsTask()
{
var result = DynamicMcpToolExtensions.DeriveServiceName(typeof(ITaskService));
Assert.Equal("task", result);
}
/// <summary>
/// DeriveServiceName:复合接口名转 snake_case。
/// </summary>
[Fact]
public void DeriveServiceName_MultiWordInterface_ReturnsSnakeCase()
{
// 假设存在 ICloudSyncService → cloud_sync
// 用动态类型模拟
var result = DynamicMcpToolExtensions.DeriveServiceName(typeof(ITestMultiWordService));
Assert.Equal("test_multi_word", result);
}
/// <summary>
/// ToSnakeCasePascalCase → snake_case
/// </summary>
[Fact]
public void ToSnakeCase_PascalCase_ConvertsToSnakeCase()
{
Assert.Equal("get_all", DynamicMcpToolExtensions.ToSnakeCase("GetAll"));
Assert.Equal("create_task", DynamicMcpToolExtensions.ToSnakeCase("CreateTask"));
Assert.Equal("toggle_complete", DynamicMcpToolExtensions.ToSnakeCase("ToggleComplete"));
Assert.Equal("id", DynamicMcpToolExtensions.ToSnakeCase("Id"));
Assert.Equal("", DynamicMcpToolExtensions.ToSnakeCase(""));
}
#endregion
#region
/// <summary>
/// 完整工具名:prefix + 方法名 = task_get_all_tasks
/// 验证 StripAsyncSuffix 和 ToSnakeCase 的组合效果。
/// </summary>
[Fact]
public void ToolNaming_PrefixAndMethod_FormatsCorrectly()
{
var prefix = DynamicMcpToolExtensions.DeriveServiceName(typeof(ITaskService));
Assert.Equal("task", prefix);
// GetActiveTasksAsync → get_active_tasks
var methodSuffix = DynamicMcpToolExtensions.ToSnakeCase(
StripAsyncSuffix("GetActiveTasksAsync"));
Assert.Equal("get_active_tasks", methodSuffix);
}
/// <summary>
/// StripAsyncSuffix:去掉 Async 后缀。
/// </summary>
[Fact]
public void StripAsyncSuffix_RemovesAsync()
{
Assert.Equal("GetAllTasks", StripAsyncSuffix("GetAllTasksAsync"));
Assert.Equal("Create", StripAsyncSuffix("CreateAsync"));
Assert.Equal("Toggle", StripAsyncSuffix("ToggleAsync"));
Assert.Equal("Delete", StripAsyncSuffix("DeleteAsync"));
Assert.Equal("GetById", StripAsyncSuffix("GetById")); // 无 Async 后缀不修改
}
#endregion
#region
/// <summary>
/// GetMethodDescription:方法名 → 中文描述
/// </summary>
[Fact]
public void GetMethodDescription_GeneratesChineseDescription()
{
var getMethod = typeof(ITaskService).GetMethod(nameof(ITaskService.GetAllTasksAsync))!;
var createMethod = typeof(ITaskService).GetMethod(nameof(ITaskService.CreateTaskAsync))!;
var updateMethod = typeof(ITaskService).GetMethod(nameof(ITaskService.UpdateTaskAsync))!;
var deleteMethod = typeof(ITaskService).GetMethod(nameof(ITaskService.DeleteTaskAsync))!;
var toggleMethod = typeof(ITaskService).GetMethod(nameof(ITaskService.ToggleCompleteAsync))!;
Assert.StartsWith("获取", GetMethodDescription(getMethod));
Assert.StartsWith("创建", GetMethodDescription(createMethod));
Assert.StartsWith("更新", GetMethodDescription(updateMethod));
Assert.StartsWith("删除", GetMethodDescription(deleteMethod));
Assert.StartsWith("切换", GetMethodDescription(toggleMethod));
}
/// <summary>
/// GetMethodDescription:带 DescriptionAttribute 时优先使用。
/// </summary>
[Fact]
public void GetMethodDescription_UsesDescriptionAttribute()
{
var method = typeof(ITestAnnotatedService).GetMethod(nameof(ITestAnnotatedService.DoSomething))!;
var desc = GetMethodDescription(method);
Assert.Equal("执行自定义操作", desc);
}
#endregion
#region
/// <summary>
/// IsReadOnlyMethodGet/List/Query/Search 前缀返回 true。
/// </summary>
[Fact]
public void IsReadOnlyMethod_GetPrefix_ReturnsTrue()
{
Assert.True(IsReadOnlyMethod("GetAllTasksAsync"));
Assert.True(IsReadOnlyMethod("ListSubTodosAsync"));
Assert.True(IsReadOnlyMethod("QueryByDateAsync"));
Assert.True(IsReadOnlyMethod("SearchByKeywordAsync"));
}
/// <summary>
/// IsReadOnlyMethod:非只读前缀返回 false。
/// </summary>
[Fact]
public void IsReadOnlyMethod_NonReadPrefix_ReturnsFalse()
{
Assert.False(IsReadOnlyMethod("CreateTaskAsync"));
Assert.False(IsReadOnlyMethod("UpdateTaskAsync"));
Assert.False(IsReadOnlyMethod("DeleteTaskAsync"));
Assert.False(IsReadOnlyMethod("ToggleCompleteAsync"));
}
/// <summary>
/// IsDestructiveMethodDelete/Remove 前缀返回 true。
/// </summary>
[Fact]
public void IsDestructiveMethod_DeletePrefix_ReturnsTrue()
{
Assert.True(IsDestructiveMethod("DeleteTaskAsync"));
Assert.True(IsDestructiveMethod("RemoveItemAsync"));
}
/// <summary>
/// IsDestructiveMethod:非破坏性操作返回 false。
/// </summary>
[Fact]
public void IsDestructiveMethod_NonDestructive_ReturnsFalse()
{
Assert.False(IsDestructiveMethod("GetAllTasksAsync"));
Assert.False(IsDestructiveMethod("CreateTaskAsync"));
Assert.False(IsDestructiveMethod("UpdateTaskAsync"));
}
#endregion
#region
/// <summary>
/// 验证 WithDynamicApiTools 扫描 IDynamicApiService 所在程序集,能发现所有实现接口并生成工具。
/// ITaskService9+ IVoiceService4+ IMeetingService5+ IAttachmentService5= 23。
/// CloudSync 服务接口已通过 [RemoteService(IsEnabled = false)] 排除。
/// </summary>
[Fact]
public void WithDynamicApiTools_ScansAssemblyAndRegistersTools()
{
var services = new ServiceCollection();
services.AddScoped<ITaskService, MockTaskService>();
services.AddLogging();
services.AddMcpServer().WithDynamicApiTools();
var provider = services.BuildServiceProvider();
var tools = provider.GetServices<McpServerTool>().ToList();
Assert.NotEmpty(tools);
// ITaskService9+ IVoiceService4+ IMeetingService5+ IAttachmentService5= 23
Assert.Equal(23, tools.Count);
// ITaskService 工具名应与推导规则一致
var toolNames = tools.Select(t => t.ProtocolTool.Name).OrderBy(n => n).ToList();
Assert.Contains("task_get_all_tasks", toolNames);
Assert.Contains("task_get_task_by_id", toolNames);
Assert.Contains("task_get_active_tasks", toolNames);
Assert.Contains("task_get_completed_tasks", toolNames);
Assert.Contains("task_create_task", toolNames);
Assert.Contains("task_update_task", toolNames);
Assert.Contains("task_toggle_complete", toolNames);
Assert.Contains("task_delete_task", toolNames);
Assert.Contains("task_get_sub_tasks", toolNames);
// IVoiceService 工具名也应存在
Assert.Contains("voice_execute_command", toolNames);
Assert.Contains("voice_confirm_command", toolNames);
Assert.Contains("voice_get_breakdown", toolNames);
Assert.Contains("voice_confirm_breakdown", toolNames);
}
/// <summary>
/// 验证生成的工具携带正确的行为注解(ReadOnly/Destructive)。
/// </summary>
[Fact]
public void WithDynamicApiTools_ToolsHaveCorrectBehaviorAnnotations()
{
// Arrange
var services = new ServiceCollection();
services.AddScoped<ITaskService, MockTaskService>();
// Act
services.AddMcpServer()
.WithDynamicApiTools();
var provider = services.BuildServiceProvider();
var tools = provider.GetServices<McpServerTool>().ToList();
// Assert
var getTool = tools.First(t => t.ProtocolTool.Name == "task_get_all_tasks");
Assert.True(getTool.ProtocolTool.Annotations?.ReadOnlyHint == true);
var deleteTool = tools.First(t => t.ProtocolTool.Name == "task_delete_task");
Assert.True(deleteTool.ProtocolTool.Annotations?.DestructiveHint == true);
}
/// <summary>
/// 验证所有生成的 MCP 工具都携带有意义的中文描述。
/// 描述不得为空,且至少包含一个中文字符。
/// </summary>
[Fact]
public void WithDynamicApiTools_AllToolsHaveMeaningfulDescriptions()
{
// Arrange
var services = new ServiceCollection();
services.AddScoped<ITaskService, MockTaskService>();
// Act
services.AddMcpServer()
.WithDynamicApiTools();
var provider = services.BuildServiceProvider();
var tools = provider.GetServices<McpServerTool>().ToList();
// Assert: 每个工具的描述非空且包含中文
foreach (var tool in tools)
{
var desc = tool.ProtocolTool.Description;
Assert.False(string.IsNullOrWhiteSpace(desc),
$"工具 {tool.ProtocolTool.Name} 的描述不应为空");
// 验证包含中文字符(Unicode 范围 CJK Unified Ideographs
var hasChinese = desc.Any(c => c >= 0x4E00 && c <= 0x9FFF);
Assert.True(hasChinese,
$"工具 {tool.ProtocolTool.Name} 的描述应包含中文,当前: {desc}");
}
}
/// <summary>
/// 验证有参数的工具(CreateTask、UpdateTask、ToggleComplete、DeleteTask、GetTaskById、GetSubTasks
/// 生成了 InputSchema;无参数的工具(GetAllTasks、GetActiveTasks、GetCompletedTasks
/// 的 InputSchema 为默认空结构。
/// </summary>
[Fact]
public void WithDynamicApiTools_ToolsHaveInputSchemas()
{
// Arrange
var services = new ServiceCollection();
services.AddScoped<ITaskService, MockTaskService>();
// Act
services.AddMcpServer()
.WithDynamicApiTools();
var provider = services.BuildServiceProvider();
var tools = provider.GetServices<McpServerTool>().ToList();
// 有参数的方法应生成非空 InputSchema
var toolsWithParams = new[] { "task_create_task", "task_update_task", "task_toggle_complete",
"task_delete_task", "task_get_task_by_id", "task_get_sub_tasks" };
foreach (var name in toolsWithParams)
{
var tool = tools.First(t => t.ProtocolTool.Name == name);
var schema = tool.ProtocolTool.InputSchema;
Assert.True(schema.ValueKind != JsonValueKind.Undefined,
$"工具 {name} 应有 InputSchema");
}
// 无参数的方法应有空 InputSchema(SDK 默认行为)
var toolsWithoutParams = new[] { "task_get_all_tasks", "task_get_active_tasks", "task_get_completed_tasks" };
foreach (var name in toolsWithoutParams)
{
var tool = tools.First(t => t.ProtocolTool.Name == name);
// 无参数方法:InputSchema 可能为 Undefined 或空对象
Assert.True(tool.ProtocolTool.InputSchema.ValueKind == JsonValueKind.Undefined
|| (tool.ProtocolTool.InputSchema.ValueKind == JsonValueKind.Object
&& tool.ProtocolTool.InputSchema.GetProperty("type").GetString() == "object"),
$"无参数工具 {name} 的 InputSchema 应为 Undefined 或空 object 类型");
}
}
/// <summary>
/// 通过 DI 直接解析 MockTaskService 并调用其方法,
/// 验证服务实现可正常工作(返回预期类型与值)。
/// </summary>
[Fact]
public async Task MockTaskService_Invocation_ReturnsExpectedResults()
{
// Arrange
var services = new ServiceCollection();
services.AddScoped<ITaskService, MockTaskService>();
var provider = services.BuildServiceProvider();
var service = provider.GetRequiredService<ITaskService>();
// Act & Assert: 无参查询方法
var allTasks = await service.GetAllTasksAsync();
Assert.NotNull(allTasks);
Assert.Empty(allTasks);
var activeTasks = await service.GetActiveTasksAsync();
Assert.NotNull(activeTasks);
Assert.Empty(activeTasks);
var completedTasks = await service.GetCompletedTasksAsync();
Assert.NotNull(completedTasks);
Assert.Empty(completedTasks);
// Act & Assert: 单参查询方法
var testId = Guid.NewGuid();
var taskById = await service.GetTaskByIdAsync(testId);
Assert.Null(taskById);
var subTasks = await service.GetSubTasksAsync(testId);
Assert.NotNull(subTasks);
Assert.Empty(subTasks);
// Act & Assert: 创建任务
var created = await service.CreateTaskAsync(new CreateTaskDto { Title = "测试任务" });
Assert.NotNull(created);
// Act & Assert: 更新任务
var updated = await service.UpdateTaskAsync(new UpdateTaskDto { Id = testId, Title = "已更新" });
Assert.NotNull(updated);
// Act & Assert: 切换完成状态
var toggled = await service.ToggleCompleteAsync(testId);
Assert.NotNull(toggled);
// Act & Assert: 删除任务(void 方法不抛异常即为成功)
await service.DeleteTaskAsync(testId);
}
/// <summary>
/// 验证 MCP 工具可被调用(通过 McpServerTool.InvokeAsync)。
/// 创建一个最小化的 RequestContext 并调用 task_get_all_tasks 工具。
/// </summary>
[Fact]
public async Task WithDynamicApiTools_InvokeTaskGetAllTasks_ReturnsResult()
{
// Arrange
var services = new ServiceCollection();
services.AddScoped<ITaskService, MockTaskService>();
services.AddMcpServer()
.WithDynamicApiTools();
var provider = services.BuildServiceProvider();
var tool = provider.GetServices<McpServerTool>()
.First(t => t.ProtocolTool.Name == "task_get_all_tasks");
// 通过 RuntimeHelpers 创建未初始化的 RequestContext
// 然后手动注入 ServiceProvider(工具调用只需 Services 属性)。
var ctxType = typeof(RequestContext<>).MakeGenericType(typeof(CallToolRequestParams));
var requestContext = System.Runtime.CompilerServices.RuntimeHelpers
.GetUninitializedObject(ctxType) as RequestContext<CallToolRequestParams>;
Assert.NotNull(requestContext);
// 注入 ServiceProvider 以便 createTargetFunc 能解析 ITaskService
typeof(MessageContext).GetProperty("Services")!.SetValue(requestContext, provider);
// Act
var result = await tool.InvokeAsync(requestContext!, CancellationToken.None);
// Assert
Assert.NotNull(result);
Assert.False(result.IsError == true, $"调用应成功");
Assert.NotNull(result.Content);
Assert.NotEmpty(result.Content);
}
#endregion
#region private
private static string StripAsyncSuffix(string name)
=> name.EndsWith("Async") ? name[..^5] : name;
private static bool IsReadOnlyMethod(string name)
=> name.StartsWith("Get") || name.StartsWith("List")
|| name.StartsWith("Query") || name.StartsWith("Search");
private static bool IsDestructiveMethod(string name)
=> name.StartsWith("Delete") || name.StartsWith("Remove");
private static string GetMethodDescription(MethodInfo method)
{
var descAttr = method.GetCustomAttribute<DescriptionAttribute>();
if (descAttr != null) return descAttr.Description;
var name = method.Name;
if (name.StartsWith("Get")) return $"获取{StripAsyncSuffix(name[3..])}";
if (name.StartsWith("Create")) return $"创建{StripAsyncSuffix(name[6..])}";
if (name.StartsWith("Update")) return $"更新{StripAsyncSuffix(name[6..])}";
if (name.StartsWith("Delete")) return $"删除{StripAsyncSuffix(name[6..])}";
if (name.StartsWith("Toggle")) return $"切换{StripAsyncSuffix(name[6..])}";
return StripAsyncSuffix(name);
}
#endregion
}
#region
/// <summary>
/// Mock 多词接口,用于测试 snake_case 转换。
/// </summary>
public interface ITestMultiWordService : IDynamicApiService
{
Task<int> GetValueAsync();
}
/// <summary>
/// Mock 带 DescriptionAttribute 的接口。
/// </summary>
public interface ITestAnnotatedService : IDynamicApiService
{
[Description("执行自定义操作")]
Task<int> DoSomething();
}
/// <summary>
/// Mock 动态 API 服务接口(含简单参数方法)。
/// </summary>
public interface IMockTestService : IDynamicApiService
{
[Description("获取数据")]
Task<string> GetDataAsync();
[Description("更新数据")]
Task UpdateDataAsync(int id, string value);
}
/// <summary>
/// Mock 动态 API 服务实现。
/// </summary>
public class MockTestServiceImpl : IMockTestService
{
public Task<string> GetDataAsync() => Task.FromResult("mock data");
public Task UpdateDataAsync(int id, string value) => Task.CompletedTask;
}
/// <summary>
/// Mock ITaskService 实现(桩实现,仅用于验证工具注册,不执行实际逻辑)。
/// </summary>
public class MockTaskService : ITaskService
{
public Task<List<TaskDto>> GetAllTasksAsync() => Task.FromResult(new List<TaskDto>());
public Task<TaskDto?> GetTaskByIdAsync(Guid id) => Task.FromResult<TaskDto?>(null);
public Task<List<TaskDto>> GetActiveTasksAsync() => Task.FromResult(new List<TaskDto>());
public Task<List<TaskDto>> GetCompletedTasksAsync() => Task.FromResult(new List<TaskDto>());
public Task<TaskDto> CreateTaskAsync(CreateTaskDto dto) => Task.FromResult(new TaskDto());
public Task<TaskDto> UpdateTaskAsync(UpdateTaskDto dto) => Task.FromResult(new TaskDto());
public Task<TaskDto> ToggleCompleteAsync(Guid id) => Task.FromResult(new TaskDto());
public Task DeleteTaskAsync(Guid id) => Task.CompletedTask;
public Task<List<TaskDto>> GetSubTasksAsync(Guid parentTaskId) => Task.FromResult(new List<TaskDto>());
}
#endregion
@@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<IsTestProject>true</IsTestProject>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.9" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.collector" Version="6.0.2">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.InMemory" Version="10.0.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Hua.Todo.Application\Hua.Todo.Application.csproj" />
<ProjectReference Include="..\..\src\Hua.Todo.Core\Hua.Todo.Core.csproj" />
<ProjectReference Include="..\..\src\Hua.Todo.Host\Hua.Todo.Host.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,432 @@
using System.Reflection;
using Hua.Todo.Application.Services.Meeting;
using Hua.Todo.Application.Services.Meeting.Models;
using Hua.Todo.Core.Entities;
using Xunit;
namespace Hua.Todo.Host.Tests.Meeting;
/// <summary>
/// 工单 03-03 AI 任务拆分服务测试。
/// </summary>
public class MeetingAiBreakdownTests
{
[Fact]
public void MeetingTaskSuggestion_DefaultProperties()
{
var suggestion = new MeetingTaskSuggestion
{
Title = "整理需求文档",
Priority = TaskPriority.High,
Reason = "会议中提及需要在周五前完成"
};
Assert.Equal("整理需求文档", suggestion.Title);
Assert.Equal(TaskPriority.High, suggestion.Priority);
Assert.Equal("会议中提及需要在周五前完成", suggestion.Reason);
}
[Fact]
public void MeetingTaskSuggestion_DefaultValues()
{
var suggestion = new MeetingTaskSuggestion();
Assert.Equal(string.Empty, suggestion.Title);
Assert.Equal(TaskPriority.Medium, suggestion.Priority);
Assert.Equal(string.Empty, suggestion.Reason);
}
[Fact]
public void BreakdownRequest_CanBeCreated()
{
var taskId = Guid.NewGuid();
var request = new BreakdownRequest
{
TaskId = taskId,
Notes = "会议讨论了三个议题"
};
Assert.Equal(taskId, request.TaskId);
Assert.Equal("会议讨论了三个议题", request.Notes);
}
[Fact]
public void BreakdownRequest_NotesCanBeNull()
{
var taskId = Guid.NewGuid();
var request = new BreakdownRequest
{
TaskId = taskId,
Notes = null
};
Assert.Equal(taskId, request.TaskId);
Assert.Null(request.Notes);
}
[Fact]
public void BreakdownResponse_ContainsCorrectData()
{
var suggestions = new List<MeetingTaskSuggestion>
{
new() { Title = "任务A", Priority = TaskPriority.High, Reason = "原因A" },
new() { Title = "任务B", Priority = TaskPriority.Low, Reason = "原因B" }
};
var taskId = Guid.NewGuid();
var response = new BreakdownResponse
{
TaskId = taskId,
Suggestions = suggestions
};
Assert.Equal(taskId, response.TaskId);
Assert.Equal(2, response.Suggestions.Count);
Assert.Equal("任务A", response.Suggestions[0].Title);
}
[Fact]
public void BreakdownResponse_DefaultSuggestionsIsEmpty()
{
var response = new BreakdownResponse();
Assert.NotNull(response.Suggestions);
Assert.Empty(response.Suggestions);
}
[Fact]
public void SubTaskCreateItem_CanBeCreated()
{
var item = new SubTaskCreateItem
{
Title = "子任务标题",
Priority = TaskPriority.High
};
Assert.Equal("子任务标题", item.Title);
Assert.Equal(TaskPriority.High, item.Priority);
}
[Fact]
public void SubTaskCreateItem_DefaultPriorityIsMedium()
{
var item = new SubTaskCreateItem();
Assert.Equal(TaskPriority.Medium, item.Priority);
}
[Fact]
public void ConfirmBreakdownRequest_CanBeCreated()
{
var taskId = Guid.NewGuid();
var request = new ConfirmBreakdownRequest
{
TaskId = taskId,
SubTasks = new List<SubTaskCreateItem>
{
new() { Title = "任务1" },
new() { Title = "任务2", Priority = TaskPriority.High }
}
};
Assert.Equal(taskId, request.TaskId);
Assert.Equal(2, request.SubTasks.Count);
Assert.Equal("任务1", request.SubTasks[0].Title);
Assert.Equal(TaskPriority.High, request.SubTasks[1].Priority);
}
[Fact]
public void ConfirmBreakdownRequest_DefaultSubTasksIsEmpty()
{
var request = new ConfirmBreakdownRequest();
Assert.NotNull(request.SubTasks);
Assert.Empty(request.SubTasks);
}
[Fact]
public void BatchCreateResult_CanBeCreated()
{
var result = new BatchCreateResult
{
CreatedCount = 3,
SubTasks = new List<Application.Models.TaskDto>
{
new() { Id = Guid.NewGuid(), Title = "任务1" },
new() { Id = Guid.NewGuid(), Title = "任务2" },
new() { Id = Guid.NewGuid(), Title = "任务3" }
}
};
Assert.Equal(3, result.CreatedCount);
Assert.Equal(3, result.SubTasks.Count);
Assert.Equal("任务1", result.SubTasks[0].Title);
}
[Fact]
public void BatchCreateResult_DefaultSubTasksIsEmpty()
{
var result = new BatchCreateResult();
Assert.Equal(0, result.CreatedCount);
Assert.NotNull(result.SubTasks);
Assert.Empty(result.SubTasks);
}
/// <summary>
/// 测试 ParseBreakdownJson 解析有效的 LLM JSON 响应。
/// </summary>
[Fact]
public void ParseBreakdownJson_ValidJson_ReturnsSuggestions()
{
string json = """
{
"suggestions": [
{
"title": "整理需求文档",
"priority": 1,
"reason": "会议中决定需要整理"
},
{
"title": "安排评审会议",
"priority": 2,
"reason": "下周前需要完成评审"
}
]
}
""";
var result = MeetingAiBreakdownService.ParseBreakdownJson(json);
Assert.Equal(2, result.Count);
Assert.Equal("整理需求文档", result[0].Title);
Assert.Equal(TaskPriority.Medium, result[0].Priority);
Assert.Equal("会议中决定需要整理", result[0].Reason);
Assert.Equal("安排评审会议", result[1].Title);
Assert.Equal(TaskPriority.High, result[1].Priority);
Assert.Equal("下周前需要完成评审", result[1].Reason);
}
/// <summary>
/// 测试 ParseBreakdownJson 解析被 markdown 代码块包裹的 LLM 响应。
/// </summary>
[Fact]
public void ParseBreakdownJson_MarkdownWrapped_StripsMarkdown()
{
string json = """
```json
{
"suggestions": [
{
"title": "更新项目计划",
"priority": 1,
"reason": "里程碑已调整"
}
]
}
```
""";
var result = MeetingAiBreakdownService.ParseBreakdownJson(json);
Assert.Single(result);
Assert.Equal("更新项目计划", result[0].Title);
}
/// <summary>
/// 测试 ParseBreakdownJson 处理空建议列表。
/// </summary>
[Fact]
public void ParseBreakdownJson_EmptySuggestions_ReturnsEmptyList()
{
string json = """{"suggestions":[]}""";
var result = MeetingAiBreakdownService.ParseBreakdownJson(json);
Assert.Empty(result);
}
/// <summary>
/// 测试 ParseBreakdownJson 过滤标题为空的条目。
/// </summary>
[Fact]
public void ParseBreakdownJson_FiltersEmptyTitle()
{
string json = """
{
"suggestions": [
{
"title": "",
"priority": 1,
"reason": "空标题"
},
{
"title": "有效任务",
"priority": 0,
"reason": "有标题"
}
]
}
""";
var result = MeetingAiBreakdownService.ParseBreakdownJson(json);
Assert.Single(result);
Assert.Equal("有效任务", result[0].Title);
}
/// <summary>
/// 测试 ParseBreakdownJson 过滤空白标题的条目。
/// </summary>
[Fact]
public void ParseBreakdownJson_FiltersWhitespaceTitle()
{
string json = """
{
"suggestions": [
{
"title": " ",
"priority": 1,
"reason": "空白标题"
},
{
"title": "有效任务",
"priority": 0,
"reason": "有标题"
}
]
}
""";
var result = MeetingAiBreakdownService.ParseBreakdownJson(json);
Assert.Single(result);
Assert.Equal("有效任务", result[0].Title);
}
/// <summary>
/// 测试 ParseBreakdownJson 处理无效 JSON。
/// </summary>
[Fact]
public void ParseBreakdownJson_InvalidJson_ReturnsEmptyList()
{
string json = "这不是有效的 JSON";
var result = MeetingAiBreakdownService.ParseBreakdownJson(json);
Assert.Empty(result);
}
/// <summary>
/// 测试 ParseBreakdownJson 处理空字符串。
/// </summary>
[Fact]
public void ParseBreakdownJson_EmptyString_ReturnsEmptyList()
{
var result = MeetingAiBreakdownService.ParseBreakdownJson("");
Assert.Empty(result);
}
/// <summary>
/// 测试 ParseBreakdownJson 返回的条目标题已 Trim。
/// </summary>
[Fact]
public void ParseBreakdownJson_TrimsTitleWhitespace()
{
string json = """
{
"suggestions": [
{
"title": " 前后空格 ",
"priority": 1,
"reason": "测试"
}
]
}
""";
var result = MeetingAiBreakdownService.ParseBreakdownJson(json);
Assert.Single(result);
Assert.Equal("前后空格", result[0].Title);
}
/// <summary>
/// 测试 ParseBreakdownJson 处理 Reason 为 null 的条目。
/// </summary>
[Fact]
public void ParseBreakdownJson_NullReason_ReturnsEmptyReason()
{
string json = """
{
"suggestions": [
{
"title": "测试任务",
"priority": 1
}
]
}
""";
var result = MeetingAiBreakdownService.ParseBreakdownJson(json);
Assert.Single(result);
Assert.Equal("测试任务", result[0].Title);
Assert.Equal(string.Empty, result[0].Reason);
}
/// <summary>
/// 测试 IMeetingService 接口包含 RequestBreakdownAsync 方法。
/// </summary>
[Fact]
public void IMeetingService_HasRequestBreakdownAsyncMethod()
{
var method = typeof(IMeetingService).GetMethod("RequestBreakdownAsync");
Assert.NotNull(method);
Assert.Equal(typeof(Task<BreakdownResponse>), method!.ReturnType);
}
/// <summary>
/// 测试 IMeetingService 接口包含 ConfirmBreakdownAsync 方法。
/// </summary>
[Fact]
public void IMeetingService_HasConfirmBreakdownAsyncMethod()
{
var method = typeof(IMeetingService).GetMethod("ConfirmBreakdownAsync");
Assert.NotNull(method);
Assert.Equal(typeof(Task<BatchCreateResult>), method!.ReturnType);
}
/// <summary>
/// 测试 MeetingAiBreakdownService 类型存在并具有所需方法。
/// </summary>
[Fact]
public void MeetingAiBreakdownService_HasAnalyzeAsyncMethod()
{
var method = typeof(MeetingAiBreakdownService).GetMethod("AnalyzeAsync");
Assert.NotNull(method);
}
/// <summary>
/// 测试 MeetingAiBreakdownService 类型存在并具有 ConfirmAndCreateAsync 方法。
/// </summary>
[Fact]
public void MeetingAiBreakdownService_HasConfirmAndCreateAsyncMethod()
{
var method = typeof(MeetingAiBreakdownService).GetMethod("ConfirmAndCreateAsync");
Assert.NotNull(method);
}
/// <summary>
/// 测试 MeetingAiBreakdownService 类型存在并具有 ParseBreakdownJson 公开静态方法。
/// </summary>
[Fact]
public void MeetingAiBreakdownService_HasParseBreakdownJsonMethod()
{
var method = typeof(MeetingAiBreakdownService).GetMethod("ParseBreakdownJson",
BindingFlags.Public | BindingFlags.Static);
Assert.NotNull(method);
Assert.True(method!.IsStatic);
}
}
@@ -0,0 +1,158 @@
using Hua.Todo.Application.Services.Meeting.Models;
using Hua.Todo.Core.Entities;
using Xunit;
namespace Hua.Todo.Host.Tests.Meeting;
/// <summary>
/// 工单 03-01 会议数据模型与 API 测试。
/// </summary>
public class MeetingModelTests
{
[Fact]
public void TaskType_Normal_IsZero()
{
Assert.Equal(0, (int)TaskType.Normal);
}
[Fact]
public void TaskType_Meeting_IsOne()
{
Assert.Equal(1, (int)TaskType.Meeting);
}
[Fact]
public void TaskEntity_DefaultTaskType_IsNormal()
{
var entity = new TaskEntity();
Assert.Equal(TaskType.Normal, entity.TaskType);
}
[Fact]
public void TaskEntity_DefaultMeetingNotes_IsNull()
{
var entity = new TaskEntity();
Assert.Null(entity.MeetingNotes);
}
[Fact]
public void TaskEntity_DefaultAudioDuration_IsNull()
{
var entity = new TaskEntity();
Assert.Null(entity.AudioDuration);
}
[Fact]
public void TaskEntity_MeetingFields_CanBeSet()
{
var entity = new TaskEntity
{
TaskType = TaskType.Meeting,
MeetingNotes = "会议讨论了三个议题",
AudioDuration = 1830.5
};
Assert.Equal(TaskType.Meeting, entity.TaskType);
Assert.Equal("会议讨论了三个议题", entity.MeetingNotes);
Assert.Equal(1830.5, entity.AudioDuration);
}
[Fact]
public void SaveNotesRequest_CanBeCreated()
{
var taskId = Guid.NewGuid();
var request = new SaveNotesRequest
{
TaskId = taskId,
Notes = "会议纪要内容"
};
Assert.Equal(taskId, request.TaskId);
Assert.Equal("会议纪要内容", request.Notes);
}
[Fact]
public void SaveNotesResponse_ContainsCorrectData()
{
var taskId = Guid.NewGuid();
var response = new SaveNotesResponse
{
TaskId = taskId,
MeetingNotes = "已保存的纪要"
};
Assert.Equal(taskId, response.TaskId);
Assert.Equal("已保存的纪要", response.MeetingNotes);
}
[Fact]
public void MeetingDetailResponse_ContainsCorrectData()
{
var taskId = Guid.NewGuid();
var response = new MeetingDetailResponse
{
TaskId = taskId,
Title = "周一产品评审会",
MeetingNotes = "纪要内容",
AudioDuration = 900.0,
IsCompleted = false
};
Assert.Equal(taskId, response.TaskId);
Assert.Equal("周一产品评审会", response.Title);
Assert.Equal("纪要内容", response.MeetingNotes);
Assert.Equal(900.0, response.AudioDuration);
Assert.False(response.IsCompleted);
}
[Fact]
public void TaskEntity_MeetingType_CanBeChildTask()
{
var parentId = Guid.NewGuid();
var parent = new TaskEntity
{
Id = parentId,
Title = "周一例会",
TaskType = TaskType.Meeting
};
var child = new TaskEntity
{
Id = Guid.NewGuid(),
Title = "准备PPT",
ParentTaskId = parentId,
ParentTask = parent
};
parent.SubTasks.Add(child);
Assert.Single(parent.SubTasks);
Assert.Equal(TaskType.Meeting, parent.TaskType);
Assert.Equal(parentId, child.ParentTaskId);
}
[Fact]
public void TaskType_IsDefinedOnEntity()
{
// 验证 TaskType 是 TaskEntity 的有效属性
var property = typeof(TaskEntity).GetProperty("TaskType");
Assert.NotNull(property);
Assert.Equal(typeof(TaskType), property.PropertyType);
}
[Fact]
public void MeetingNotes_IsDefinedOnEntity()
{
var property = typeof(TaskEntity).GetProperty("MeetingNotes");
Assert.NotNull(property);
Assert.Equal(typeof(string), property.PropertyType);
}
[Fact]
public void AudioDuration_IsDefinedOnEntity()
{
var property = typeof(TaskEntity).GetProperty("AudioDuration");
Assert.NotNull(property);
Assert.Equal(typeof(double?), property.PropertyType);
}
}
@@ -0,0 +1,107 @@
using Hua.Todo.Application.Services.Meeting;
using Hua.Todo.Application.Services.Meeting.Models;
using Xunit;
namespace Hua.Todo.Host.Tests.Meeting;
/// <summary>
/// 工单 03-02 音频录制与转写测试。
/// </summary>
public class MeetingTranscriptionTests
{
[Fact]
public void TranscribeRequest_CanBeCreated()
{
var taskId = Guid.NewGuid();
var request = new TranscribeRequest
{
TaskId = taskId,
AudioBase64 = "dGVzdA==",
Format = "webm",
AudioDuration = 90.0
};
Assert.Equal(taskId, request.TaskId);
Assert.Equal("dGVzdA==", request.AudioBase64);
Assert.Equal("webm", request.Format);
Assert.Equal(90.0, request.AudioDuration);
}
[Fact]
public void TranscribeResponse_CanBeCreated()
{
var taskId = Guid.NewGuid();
var response = new TranscribeResponse
{
TaskId = taskId,
Transcript = "这是转写后的文字内容",
AudioDuration = 90.0
};
Assert.Equal(taskId, response.TaskId);
Assert.Equal("这是转写后的文字内容", response.Transcript);
Assert.Equal(90.0, response.AudioDuration);
}
[Fact]
public void TranscribeResponse_DefaultValues()
{
var response = new TranscribeResponse();
Assert.Equal(Guid.Empty, response.TaskId);
Assert.Equal(string.Empty, response.Transcript);
Assert.Equal(0.0, response.AudioDuration);
}
[Fact]
public void TranscribeRequest_DefaultValues()
{
var request = new TranscribeRequest();
Assert.Equal(Guid.Empty, request.TaskId);
Assert.Equal(string.Empty, request.AudioBase64);
Assert.Equal(string.Empty, request.Format);
Assert.Equal(0.0, request.AudioDuration);
}
[Fact]
public void ISttService_InterfaceExists()
{
var type = typeof(ISttService);
Assert.True(type.IsInterface);
Assert.Contains("Hua.Todo.Application.Services.Meeting", type.Namespace);
}
[Fact]
public void SttService_ImplementsISttService()
{
var type = typeof(SttService);
Assert.True(typeof(ISttService).IsAssignableFrom(type));
}
[Fact]
public void ISttService_HasTranscribeAsyncMethod()
{
var method = typeof(ISttService).GetMethod("TranscribeAsync");
Assert.NotNull(method);
Assert.Equal(typeof(Task<string>), method.ReturnType);
var parameters = method.GetParameters();
Assert.Equal(3, parameters.Length);
Assert.Equal(typeof(Stream), parameters[0].ParameterType);
Assert.Equal(typeof(string), parameters[1].ParameterType);
Assert.Equal(typeof(CancellationToken), parameters[2].ParameterType);
}
[Fact]
public void IMeetingService_HasTranscribeAudioAsyncMethod()
{
var method = typeof(IMeetingService).GetMethod("TranscribeAudioAsync");
Assert.NotNull(method);
Assert.Equal(typeof(Task<TranscribeResponse>), method.ReturnType);
var parameters = method.GetParameters();
Assert.Single(parameters);
Assert.Equal(typeof(TranscribeRequest), parameters[0].ParameterType);
}
}
+123
View File
@@ -0,0 +1,123 @@
using Hua.Todo.Core.Entities;
using Xunit;
namespace Hua.Todo.Host.Tests;
/// <summary>
/// TaskEntity 单元测试。
/// </summary>
public class TaskEntityTests
{
/// <summary>
/// 测试 TaskEntity 继承自 FullAuditedEntityWithUser。
/// </summary>
[Fact]
public void TaskEntity_InheritsFromFullAuditedEntityWithUser()
{
// Arrange & Act
var task = new TaskEntity();
// Assert
Assert.IsAssignableFrom<FullAuditedEntityWithUser<Guid, UserEntity>>(task);
}
/// <summary>
/// 测试 TaskEntity 主键为 Guid 类型。
/// </summary>
[Fact]
public void TaskEntity_IdIsGuid()
{
// Arrange & Act
var task = new TaskEntity();
// Assert
Assert.Equal(Guid.Empty, task.Id);
}
/// <summary>
/// 测试 TaskEntity 默认值。
/// </summary>
[Fact]
public void TaskEntity_DefaultValues()
{
// Arrange & Act
var task = new TaskEntity();
// Assert
Assert.Equal(string.Empty, task.Title);
Assert.Equal(TaskPriority.Medium, task.Priority);
Assert.False(task.IsCompleted);
Assert.Null(task.ParentTaskId);
Assert.Empty(task.SubTasks);
Assert.False(task.IsDeleted);
Assert.Null(task.DeletionTime);
Assert.Null(task.DeleterId);
}
/// <summary>
/// 测试创建任务时生成 Guid。
/// </summary>
[Fact]
public void TaskEntity_NewInstance_HasUniqueId()
{
// Arrange & Act
var task1 = new TaskEntity { Id = Guid.NewGuid() };
var task2 = new TaskEntity { Id = Guid.NewGuid() };
// Assert
Assert.NotEqual(task1.Id, task2.Id);
}
}
/// <summary>
/// FullAuditedEntityWithUser 基类单元测试。
/// </summary>
public class FullAuditedEntityWithUserTests
{
/// <summary>
/// 测试 ABP 审计字段默认值。
/// </summary>
[Fact]
public void FullAuditedEntityWithUser_DefaultValues()
{
// Arrange & Act
var entity = new TestAuditedEntity();
// Assert
Assert.Equal(Guid.Empty, entity.Id);
Assert.False(entity.IsDeleted);
Assert.Null(entity.DeletionTime);
Assert.Null(entity.DeleterId);
Assert.Null(entity.ExtraProperties);
Assert.Null(entity.ConcurrencyStamp);
Assert.Equal(default, entity.CreationTime);
Assert.Null(entity.CreatorId);
Assert.Null(entity.LastModificationTime);
Assert.Null(entity.LastModifierId);
}
/// <summary>
/// 测试 IUser 接口实现。
/// </summary>
[Fact]
public void UserEntity_ImplementsIUser()
{
// Arrange & Act
var user = new UserEntity
{
Id = Guid.NewGuid(),
UserName = "testuser"
};
// Assert
Assert.IsAssignableFrom<IUser<Guid>>(user);
Assert.Equal("testuser", user.UserName);
}
/// <summary>
/// 测试子类。
/// </summary>
private class TestAuditedEntity : FullAuditedEntityWithUser<Guid, UserEntity>
{
}
}
@@ -0,0 +1,553 @@
using Hua.Todo.Application.Models;
using Hua.Todo.Application.Services.Interfaces;
using Hua.Todo.Application.Services.Voice;
using Hua.Todo.Application.Services.Voice.Models;
using Hua.Todo.Core.Entities;
using Hua.Todo.Core.Services;
using Hua.Todo.Core.Voice;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Hua.Todo.Host.Tests;
/// <summary>
/// 语音意图解析与命令执行器单元测试。
/// 覆盖 RuleIntentParser(离线规则解析)、VoiceCommandExecutor(命令执行)、模型序列化。
/// </summary>
public class VoiceIntentParserTests
{
#region RuleIntentParser
[Fact]
public void Parse_Create_ReturnsCreateIntent()
{
var parser = new RuleIntentParser();
var result = parser.ParseAsync("创建任务 开会").Result;
Assert.Equal(VoiceIntent.CREATE, result.Intent);
Assert.Equal("开会", result.Params["title"]);
Assert.True(result.Confidence >= 0.8);
}
[Fact]
public void Parse_Create_WithShortVerb_ReturnsCreateIntent()
{
var parser = new RuleIntentParser();
var result = parser.ParseAsync("加 测试").Result;
Assert.Equal(VoiceIntent.CREATE, result.Intent);
Assert.Equal("测试", result.Params["title"]);
}
[Fact]
public void Parse_Delete_ReturnsDeleteIntent()
{
var parser = new RuleIntentParser();
var result = parser.ParseAsync("删除任务 开会").Result;
Assert.Equal(VoiceIntent.DELETE, result.Intent);
Assert.Equal("开会", result.Params["targetTitle"]);
}
[Fact]
public void Parse_Complete_ReturnsCompleteIntent()
{
var parser = new RuleIntentParser();
var result = parser.ParseAsync("完成任务 开会").Result;
Assert.Equal(VoiceIntent.COMPLETE, result.Intent);
Assert.Equal("开会", result.Params["targetTitle"]);
}
[Fact]
public void Parse_Uncomplete_ReturnsUncompleteIntent()
{
var parser = new RuleIntentParser();
var result = parser.ParseAsync("取消完成 开会").Result;
Assert.Equal(VoiceIntent.UNCOMPLETE, result.Intent);
Assert.Equal("开会", result.Params["targetTitle"]);
}
[Fact]
public void Parse_Update_ReturnsUpdateIntent()
{
var parser = new RuleIntentParser();
var result = parser.ParseAsync("修改任务 开会 改为 团队会议").Result;
Assert.Equal(VoiceIntent.UPDATE, result.Intent);
Assert.Equal("开会", result.Params["targetTitle"]);
Assert.Equal("团队会议", result.Params["newTitle"]);
}
[Fact]
public void Parse_Query_ReturnsQueryIntent()
{
var parser = new RuleIntentParser();
var result = parser.ParseAsync("查看未完成任务").Result;
Assert.Equal(VoiceIntent.QUERY, result.Intent);
Assert.Equal("未完成任务", result.Params["filter"]);
}
[Fact]
public void Parse_Query_HighPriority_ReturnsQueryIntent()
{
var parser = new RuleIntentParser();
var result = parser.ParseAsync("列出高优先级任务").Result;
Assert.Equal(VoiceIntent.QUERY, result.Intent);
Assert.Equal("高优先级任务", result.Params["filter"]);
}
[Fact]
public void Parse_AddSubtask_ReturnsAddSubtaskIntent()
{
var parser = new RuleIntentParser();
var result = parser.ParseAsync("给任务开会添加子任务 准备PPT").Result;
Assert.Equal(VoiceIntent.ADD_SUBTASK, result.Intent);
Assert.Equal("任务开会", result.Params["parentTitle"]);
Assert.Equal("准备PPT", result.Params["subTitle"]);
}
[Fact]
public void Parse_Unknown_ReturnsUnknownIntent()
{
var parser = new RuleIntentParser();
var result = parser.ParseAsync("今天天气真好").Result;
Assert.Equal(VoiceIntent.UNKNOWN, result.Intent);
Assert.Equal(0.0, result.Confidence);
}
[Fact]
public void Parse_AiBreakdown_ReturnsAiBreakdownIntent()
{
var parser = new RuleIntentParser();
var result = parser.ParseAsync("帮我拆分 开会").Result;
Assert.Equal(VoiceIntent.AI_BREAKDOWN, result.Intent);
Assert.Equal("开会", result.Params["targetTitle"]);
}
[Fact]
public void Parse_EmptyText_ReturnsUnknown()
{
var parser = new RuleIntentParser();
var result = parser.ParseAsync(" ").Result;
Assert.Equal(VoiceIntent.UNKNOWN, result.Intent);
}
#endregion
#region VoiceCommandExecutor Mock ITaskService
[Fact]
public async Task Execute_Create_Success()
{
var mockService = new MockTaskServiceForVoice();
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
var intentResult = new VoiceIntentResult
{
Intent = VoiceIntent.CREATE,
Params = new Dictionary<string, string> { ["title"] = "新任务" },
Confidence = 0.9
};
var response = await executor.ExecuteAsync(intentResult);
Assert.True(response.Result.Success);
Assert.Contains("已创建任务", response.Result.Message);
Assert.Single(mockService.CreatedTasks);
Assert.Equal("新任务", mockService.CreatedTasks[0].Title);
}
[Fact]
public async Task Execute_Create_WithPriority_Success()
{
var mockService = new MockTaskServiceForVoice();
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
var intentResult = new VoiceIntentResult
{
Intent = VoiceIntent.CREATE,
Params = new Dictionary<string, string> { ["title"] = "紧急任务", ["priority"] = "高" },
Confidence = 0.9
};
var response = await executor.ExecuteAsync(intentResult);
Assert.True(response.Result.Success);
Assert.Equal(TaskPriority.High, mockService.CreatedTasks[0].Priority);
}
[Fact]
public async Task Execute_Delete_Success()
{
var mockService = new MockTaskServiceForVoice();
var taskId = mockService.AddExistingTask("测试任务", false);
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
var intentResult = new VoiceIntentResult
{
Intent = VoiceIntent.DELETE,
Params = new Dictionary<string, string> { ["targetTitle"] = "测试任务" },
Confidence = 0.9
};
var response = await executor.ExecuteAsync(intentResult);
Assert.True(response.Result.Success);
Assert.Contains("已删除任务", response.Result.Message);
Assert.Single(mockService.DeletedIds);
Assert.Equal(taskId, mockService.DeletedIds[0]);
}
[Fact]
public async Task Execute_Complete_Success()
{
var mockService = new MockTaskServiceForVoice();
mockService.AddExistingTask("待完成", false);
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
var intentResult = new VoiceIntentResult
{
Intent = VoiceIntent.COMPLETE,
Params = new Dictionary<string, string> { ["targetTitle"] = "待完成" },
Confidence = 0.9
};
var response = await executor.ExecuteAsync(intentResult);
Assert.True(response.Result.Success);
Assert.Contains("已完成任务", response.Result.Message);
}
[Fact]
public async Task Execute_Ambiguity_ReturnsCandidates()
{
var mockService = new MockTaskServiceForVoice();
mockService.AddExistingTask("开会");
mockService.AddExistingTask("开会讨论方案");
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
var intentResult = new VoiceIntentResult
{
Intent = VoiceIntent.DELETE,
Params = new Dictionary<string, string> { ["targetTitle"] = "开会" },
Confidence = 0.9
};
var response = await executor.ExecuteAsync(intentResult);
Assert.False(response.Result.Success);
Assert.True(response.Result.Ambiguity);
Assert.Equal(2, response.Result.Candidates.Count);
}
[Fact]
public async Task Execute_NotFound_ReturnsError()
{
var mockService = new MockTaskServiceForVoice();
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
var intentResult = new VoiceIntentResult
{
Intent = VoiceIntent.DELETE,
Params = new Dictionary<string, string> { ["targetTitle"] = "不存在的任务" },
Confidence = 0.9
};
var response = await executor.ExecuteAsync(intentResult);
Assert.False(response.Result.Success);
Assert.False(response.Result.Ambiguity);
}
[Fact]
public async Task Execute_AddSubTask_Success()
{
var mockService = new MockTaskServiceForVoice();
var parentTaskId = mockService.AddExistingTask("父任务");
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
var intentResult = new VoiceIntentResult
{
Intent = VoiceIntent.ADD_SUBTASK,
Params = new Dictionary<string, string> { ["parentTitle"] = "父任务", ["subTitle"] = "子任务" },
Confidence = 0.9
};
var response = await executor.ExecuteAsync(intentResult);
Assert.True(response.Result.Success);
Assert.Contains("添加子任务", response.Result.Message);
Assert.Equal(parentTaskId, mockService.CreatedTasks[0].ParentTaskId);
}
[Fact]
public async Task Execute_Uncomplete_Success()
{
var mockService = new MockTaskServiceForVoice();
mockService.AddExistingTask("已完成的任务", true); // IsCompleted = true
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
var intentResult = new VoiceIntentResult
{
Intent = VoiceIntent.UNCOMPLETE,
Params = new Dictionary<string, string> { ["targetTitle"] = "已完成的任务" },
Confidence = 0.9
};
var response = await executor.ExecuteAsync(intentResult);
Assert.True(response.Result.Success);
Assert.Contains("已取消完成", response.Result.Message);
}
[Fact]
public async Task Execute_Confirmation_Complete_Success()
{
var mockService = new MockTaskServiceForVoice();
var confirmTaskId = mockService.AddExistingTask("确认测试", false);
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
var response = await executor.ExecuteConfirmedAsync(VoiceIntent.COMPLETE, confirmTaskId);
Assert.True(response.Result.Success);
Assert.Contains("已完成任务", response.Result.Message);
}
[Fact]
public async Task Execute_Confirmation_Delete_Success()
{
var mockService = new MockTaskServiceForVoice();
var deleteConfirmId = mockService.AddExistingTask("删除确认", false);
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
var response = await executor.ExecuteConfirmedAsync(VoiceIntent.DELETE, deleteConfirmId);
Assert.True(response.Result.Success);
Assert.Contains("已删除任务", response.Result.Message);
}
[Fact]
public async Task Execute_UnknownIntent_ReturnsError()
{
var mockService = new MockTaskServiceForVoice();
var executor = new VoiceCommandExecutor(mockService, NullLogger<VoiceCommandExecutor>.Instance);
var intentResult = new VoiceIntentResult
{
Intent = VoiceIntent.UNKNOWN,
Confidence = 0.0
};
var response = await executor.ExecuteAsync(intentResult);
Assert.False(response.Result.Success);
Assert.Equal("没听懂,请再说一次", response.Result.Message);
Assert.Equal(VoiceIntent.UNKNOWN, response.Intent);
}
#endregion
#region HybridVoiceIntentParser 线
[Fact]
public async Task HybridParser_OfflineMode_UsesRuleParser()
{
// 设置离线模式环境变量
Environment.SetEnvironmentVariable("VOICE_ONLINE", "false");
try
{
var parser = new HybridVoiceIntentParser(null!, new RuleIntentParser(), NullLogger<HybridVoiceIntentParser>.Instance);
var result = await parser.ParseAsync("创建任务 测试");
Assert.Equal(VoiceIntent.CREATE, result.Intent);
Assert.Equal("测试", result.Params["title"]);
}
finally
{
Environment.SetEnvironmentVariable("VOICE_ONLINE", null);
}
}
#endregion
#region AiBreakdownService
[Fact]
public async Task AiBreakdown_NoTask_ReturnsEmptyList()
{
var mockService = new MockTaskServiceForVoice();
var mockLlm = new MockLlmClientService("");
var breakdownService = new AiBreakdownService(mockLlm, mockService, NullLogger<AiBreakdownService>.Instance);
var request = new AiBreakdownRequest { TaskId = Guid.NewGuid() };
var response = await breakdownService.GetBreakdownAsync(request);
Assert.NotNull(response);
Assert.Empty(response.Suggestions);
}
[Fact]
public async Task AiBreakdown_Confirm_CreatesSubTasks()
{
var mockService = new MockTaskServiceForVoice();
var mockLlm = new MockLlmClientService("");
var breakdownService = new AiBreakdownService(mockLlm, mockService, NullLogger<AiBreakdownService>.Instance);
var confirmParentId = Guid.NewGuid();
var confirmRequest = new AiBreakdownConfirmRequest
{
ParentTaskId = confirmParentId,
SubTasks = new List<SubTaskItem>
{
new() { Title = "子任务1", Priority = TaskPriority.High },
new() { Title = "子任务2", Priority = TaskPriority.Medium }
}
};
var created = await breakdownService.ConfirmBreakdownAsync(confirmRequest);
Assert.Equal(2, created.Count);
Assert.Equal("子任务1", created[0].Title);
Assert.Equal(TaskPriority.High, created[0].Priority);
Assert.Equal(confirmParentId, created[0].ParentTaskId);
}
#endregion
}
#region Mock
/// <summary>
/// Mock ITaskService 实现,用于 VoiceCommandExecutor 和 AiBreakdownService 测试。
/// 跟踪创建、删除操作并支持预设任务数据。
/// </summary>
public class MockTaskServiceForVoice : ITaskService
{
private readonly List<TaskDto> _tasks = new();
private Guid _nextId = Guid.NewGuid();
/// <summary>
/// 跟踪已创建的任务。
/// </summary>
public List<TaskDto> CreatedTasks { get; } = new();
/// <summary>
/// 跟踪已删除的 ID。
/// </summary>
public List<Guid> DeletedIds { get; } = new();
/// <summary>
/// 预设一个已有任务,返回创建的任务 ID 供后续断言使用。
/// </summary>
public Guid AddExistingTask(string title, bool isCompleted = false)
{
var id = Guid.NewGuid();
_tasks.Add(new TaskDto
{
Id = id,
Title = title,
Priority = TaskPriority.Medium,
IsCompleted = isCompleted,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
SubTasks = new List<TaskDto>()
});
return id;
}
public Task<List<TaskDto>> GetAllTasksAsync() => Task.FromResult(_tasks.ToList());
public Task<TaskDto?> GetTaskByIdAsync(Guid id) => Task.FromResult(_tasks.FirstOrDefault(t => t.Id == id));
public Task<List<TaskDto>> GetActiveTasksAsync() => Task.FromResult(_tasks.Where(t => !t.IsCompleted).ToList());
public Task<List<TaskDto>> GetCompletedTasksAsync() => Task.FromResult(_tasks.Where(t => t.IsCompleted).ToList());
public Task<TaskDto> CreateTaskAsync(CreateTaskDto dto)
{
var task = new TaskDto
{
Id = Guid.NewGuid(),
Title = dto.Title,
Priority = dto.Priority,
IsCompleted = false,
CreatedAt = DateTime.UtcNow,
UpdatedAt = DateTime.UtcNow,
ParentTaskId = dto.ParentTaskId,
SubTasks = new List<TaskDto>()
};
_tasks.Add(task);
CreatedTasks.Add(task);
return Task.FromResult(task);
}
public Task<TaskDto> UpdateTaskAsync(UpdateTaskDto dto)
{
var task = _tasks.FirstOrDefault(t => t.Id == dto.Id);
if (task == null) throw new KeyNotFoundException($"Task with ID {dto.Id} not found");
if (!string.IsNullOrEmpty(dto.Title)) task.Title = dto.Title;
if (dto.Priority.HasValue) task.Priority = dto.Priority.Value;
task.UpdatedAt = DateTime.UtcNow;
return Task.FromResult(task);
}
public Task<TaskDto> ToggleCompleteAsync(Guid id)
{
var task = _tasks.FirstOrDefault(t => t.Id == id);
if (task == null) throw new KeyNotFoundException($"Task with ID {id} not found");
task.IsCompleted = !task.IsCompleted;
task.UpdatedAt = DateTime.UtcNow;
return Task.FromResult(task);
}
public Task DeleteTaskAsync(Guid id)
{
DeletedIds.Add(id);
_tasks.RemoveAll(t => t.Id == id);
return Task.CompletedTask;
}
public Task<List<TaskDto>> GetSubTasksAsync(Guid parentTaskId)
{
return Task.FromResult(_tasks.Where(t => t.ParentTaskId == parentTaskId).ToList());
}
}
/// <summary>
/// Mock ILlmClientService 实现,返回预设文本或抛出异常。
/// </summary>
public class MockLlmClientService : ILlmClientService
{
private readonly string _presetResponse;
/// <summary>
/// 创建 Mock LLM 客户端。
/// </summary>
/// <param name="presetResponse">预设的 LLM 响应文本。</param>
public MockLlmClientService(string presetResponse)
{
_presetResponse = presetResponse;
}
/// <summary>
/// 返回预设响应(或抛出异常以模拟 LLM 不可用)。
/// </summary>
public Task<string> SendAsync(string prompt, CancellationToken ct = default)
{
if (string.IsNullOrEmpty(_presetResponse))
throw new HttpRequestException("LLM unavailable");
return Task.FromResult(_presetResponse);
}
}
#endregion