feat: 完成云同步、语音控制与多平台扩展基础架构搭建
本次提交完成了项目核心基础架构升级: 1. 新增动态API中间件与权限控制系统,支持匿名/鉴权接口分离 2. 搭建云同步服务体系,包含认证、任务同步、安全策略等核心模块 3. 实现语音控制全链路,从STT/意图解析到命令执行 4. 新增任务类型、附件实体与相关仓储接口 5. 重构前端配置与代理规则,统一后端端口为5057 6. 新增多平台测试项目与CI脚本优化 7. 完善项目文档与代码注释规范 移除了旧版迁移文件与冗余代理配置,调整项目结构适配跨平台部署需求。
This commit is contained in:
@@ -0,0 +1,94 @@
|
||||
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.Avalonia.Tests.CloudSync;
|
||||
|
||||
/// <summary>
|
||||
/// CloudSyncProxyMiddleware 行为集成测试(Avalonia 端)。
|
||||
/// 验证云同步路径代理转发、无 URL 时返回 503。
|
||||
/// </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,29 @@
|
||||
<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.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>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Hua.Todo.Application\Hua.Todo.Application.csproj" />
|
||||
<ProjectReference Include="..\..\src\Hua.Todo.Core\Hua.Todo.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -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:同一批次中同步父子任务(均有已知 Id,lastModificationTime 为 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>
|
||||
/// DeriveServiceName:ITaskService → 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>
|
||||
/// ToSnakeCase:PascalCase → 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>
|
||||
/// IsReadOnlyMethod:Get/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>
|
||||
/// IsDestructiveMethod:Delete/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 所在程序集,能发现所有实现接口并生成工具。
|
||||
/// ITaskService(9)+ IVoiceService(4)+ IMeetingService(5)+ IAttachmentService(5)= 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);
|
||||
|
||||
// ITaskService(9)+ IVoiceService(4)+ IMeetingService(5)+ IAttachmentService(5)= 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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -0,0 +1,94 @@
|
||||
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.Maui.Tests.CloudSync;
|
||||
|
||||
/// <summary>
|
||||
/// CloudSyncProxyMiddleware 行为集成测试(MAUI 端)。
|
||||
/// 验证云同步路径代理转发、无 URL 时返回 503。
|
||||
/// </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,315 @@
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Hua.Todo.Core.Entities;
|
||||
using Xunit;
|
||||
using System.Data.Common;
|
||||
using Hua.Todo.Application.Repositories;
|
||||
|
||||
namespace Hua.Todo.Maui.Tests;
|
||||
|
||||
/// <summary>
|
||||
/// 数据库初始化集成测试,覆盖 MauiProgram.InitializeDatabase 的 Migrate → EnsureCreated 回退链路。
|
||||
/// 对应日志中 "no such table: T_Tasks" 错误场景,验证迁移链完整性与恢复能力。
|
||||
/// </summary>
|
||||
public class DatabaseInitializationTests : IDisposable
|
||||
{
|
||||
private readonly SqliteConnection _connection;
|
||||
private readonly DbContextOptions<TodoDbContext> _options;
|
||||
|
||||
public DatabaseInitializationTests()
|
||||
{
|
||||
_connection = new SqliteConnection("Data Source=:memory:");
|
||||
_connection.Open();
|
||||
|
||||
_options = new DbContextOptionsBuilder<TodoDbContext>()
|
||||
.UseSqlite(_connection)
|
||||
.Options;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
_connection.Dispose();
|
||||
}
|
||||
|
||||
private TodoDbContext CreateContext()
|
||||
{
|
||||
return new TodoDbContext(_options);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟 MauiProgram.InitializeDatabase 的 Migrate() 路径:
|
||||
/// 从零开始执行全部迁移,验证 T_Tasks 表存在。
|
||||
/// 对应日志第 1-21 行:Migrate() 失败场景。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InitializeDatabase_Migrate_FromScratch_CreatesTTasksTable()
|
||||
{
|
||||
// Arrange - 模拟 MauiProgram.InitializeDatabase 中的 Migrate() 调用
|
||||
using var context = CreateContext();
|
||||
|
||||
// Act: 执行迁移(等同于 Migrate())
|
||||
context.Database.Migrate();
|
||||
|
||||
// Assert: T_Tasks 表存在
|
||||
var tables = GetTableNames(context);
|
||||
Assert.Contains("T_Tasks", tables);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证迁移后所有核心表存在。
|
||||
/// 对应日志第 23-27 行:查询 T_Tasks 失败场景。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InitializeDatabase_Migrate_AllCoreTablesExist()
|
||||
{
|
||||
using var context = CreateContext();
|
||||
context.Database.Migrate();
|
||||
|
||||
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 表包含日志中查询的所有列。
|
||||
/// 对应日志第 24-26 行 SELECT 查询中引用的所有字段。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InitializeDatabase_TTasks_HasAllColumnsFromQuery()
|
||||
{
|
||||
using var context = CreateContext();
|
||||
context.Database.Migrate();
|
||||
|
||||
var columns = GetColumnNames(context, "T_Tasks");
|
||||
|
||||
// 日志第 24 行 SELECT 查询中引用的字段,必须全部存在
|
||||
Assert.Contains("Id", columns);
|
||||
Assert.Contains("AudioDuration", columns);
|
||||
Assert.Contains("Code", columns);
|
||||
Assert.Contains("ConcurrencyStamp", columns);
|
||||
Assert.Contains("CreationTime", columns);
|
||||
Assert.Contains("CreatorId", columns);
|
||||
Assert.Contains("DeleterId", columns);
|
||||
Assert.Contains("DeletionTime", columns);
|
||||
Assert.Contains("Description", columns);
|
||||
Assert.Contains("ExtraProperties", columns);
|
||||
Assert.Contains("IsCompleted", columns);
|
||||
Assert.Contains("IsDeleted", columns);
|
||||
Assert.Contains("LastModificationTime", columns);
|
||||
Assert.Contains("LastModifierId", columns);
|
||||
Assert.Contains("MeetingNotes", columns);
|
||||
Assert.Contains("ParentTaskId", columns);
|
||||
Assert.Contains("Priority", columns);
|
||||
Assert.Contains("TaskType", columns);
|
||||
Assert.Contains("Title", columns);
|
||||
Assert.Contains("UserId", columns);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 模拟 MauiProgram.InitializeDatabase 的 fallback 路径:
|
||||
/// Migrate 失败 → 删除数据库 → EnsureCreated 重建。
|
||||
/// 对应日志第 1 行 "Database initialization failed" 后的恢复流程。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InitializeDatabase_Fallback_EnsureCreated_AfterFailure()
|
||||
{
|
||||
// 使用文件数据库模拟 fallback 中的文件删除操作
|
||||
var tempDbPath = Path.Combine(Path.GetTempPath(), $"hua_todo_test_{Guid.NewGuid()}.db");
|
||||
try
|
||||
{
|
||||
var connectionString = $"Data Source={tempDbPath}";
|
||||
|
||||
// 先创建一个损坏的数据库(有迁移历史但表被删除)
|
||||
SqliteConnection? conn1 = null;
|
||||
try
|
||||
{
|
||||
conn1 = new SqliteConnection(connectionString);
|
||||
conn1.Open();
|
||||
var options1 = new DbContextOptionsBuilder<TodoDbContext>()
|
||||
.UseSqlite(conn1)
|
||||
.Options;
|
||||
using var context1 = new TodoDbContext(options1);
|
||||
context1.Database.Migrate();
|
||||
// 手动删除 T_Tasks 模拟损坏
|
||||
context1.Database.ExecuteSqlRaw("DROP TABLE IF EXISTS T_Tasks;");
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 显式关闭连接并清空连接池,确保文件句柄释放
|
||||
conn1?.Close();
|
||||
conn1?.Dispose();
|
||||
SqliteConnection.ClearAllPools();
|
||||
}
|
||||
|
||||
// 模拟 InitializeDatabase fallback: 删除 DB 文件,重新创建
|
||||
if (File.Exists(tempDbPath))
|
||||
{
|
||||
File.Delete(tempDbPath);
|
||||
var walPath = tempDbPath + "-wal";
|
||||
var shmPath = tempDbPath + "-shm";
|
||||
if (File.Exists(walPath)) File.Delete(walPath);
|
||||
if (File.Exists(shmPath)) File.Delete(shmPath);
|
||||
}
|
||||
|
||||
// EnsureCreated: 从当前模型创建 schema
|
||||
SqliteConnection? conn2 = null;
|
||||
try
|
||||
{
|
||||
conn2 = new SqliteConnection(connectionString);
|
||||
conn2.Open();
|
||||
var options2 = new DbContextOptionsBuilder<TodoDbContext>()
|
||||
.UseSqlite(conn2)
|
||||
.Options;
|
||||
using var context2 = new TodoDbContext(options2);
|
||||
|
||||
// Act: EnsureCreated(fallback 路径)
|
||||
context2.Database.EnsureCreated();
|
||||
|
||||
// Assert: T_Tasks 已重建
|
||||
var tables = GetTableNames(context2);
|
||||
Assert.Contains("T_Tasks", tables);
|
||||
Assert.Contains("Attachments", tables);
|
||||
}
|
||||
finally
|
||||
{
|
||||
conn2?.Close();
|
||||
conn2?.Dispose();
|
||||
SqliteConnection.ClearAllPools();
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
// 清理:确保所有连接池已释放
|
||||
SqliteConnection.ClearAllPools();
|
||||
GC.Collect();
|
||||
GC.WaitForPendingFinalizers();
|
||||
|
||||
if (File.Exists(tempDbPath)) File.Delete(tempDbPath);
|
||||
var w = tempDbPath + "-wal";
|
||||
var s = tempDbPath + "-shm";
|
||||
if (File.Exists(w)) File.Delete(w);
|
||||
if (File.Exists(s)) File.Delete(s);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证 Migrate 是幂等的:第二次调用不会报错。
|
||||
/// 模拟多次启动 MauiProgram 的场景。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InitializeDatabase_Migrate_Idempotent()
|
||||
{
|
||||
using var context = CreateContext();
|
||||
context.Database.Migrate();
|
||||
|
||||
// 第二次 Migrate 不应抛出异常
|
||||
var exception = Record.Exception(() => context.Database.Migrate());
|
||||
Assert.Null(exception);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证迁移后可以执行日志中失败的查询。
|
||||
/// 对应日志第 23-27 行 SELECT 查询。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InitializeDatabase_CanExecuteQueryFromLog()
|
||||
{
|
||||
using var context = CreateContext();
|
||||
context.Database.Migrate();
|
||||
|
||||
// 创建测试用户
|
||||
var userId = Guid.NewGuid();
|
||||
context.Users.Add(new UserEntity
|
||||
{
|
||||
Id = userId,
|
||||
UserName = "test",
|
||||
PasswordHash = "hash",
|
||||
PasswordSalt = "salt",
|
||||
Role = "User"
|
||||
});
|
||||
context.SaveChanges();
|
||||
|
||||
// 插入测试任务
|
||||
var taskId = Guid.NewGuid();
|
||||
context.Tasks.Add(new TaskEntity
|
||||
{
|
||||
Id = taskId,
|
||||
UserId = userId,
|
||||
Title = "测试",
|
||||
Priority = TaskPriority.Medium,
|
||||
Code = "T001",
|
||||
CreationTime = DateTime.UtcNow,
|
||||
CreatorId = userId
|
||||
});
|
||||
context.SaveChanges();
|
||||
|
||||
// 执行日志中的查询:LEFT JOIN T_Tasks ON ParentTaskId
|
||||
var tasks = context.Tasks
|
||||
.Include(t => t.ParentTask)
|
||||
.OrderBy(t => t.Id)
|
||||
.ToList();
|
||||
|
||||
// 验证查询不抛异常且返回结果
|
||||
Assert.NotNull(tasks);
|
||||
Assert.NotEmpty(tasks);
|
||||
Assert.Contains(tasks, t => t.Title == "测试");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// 验证迁移顺序正确:MakeTaskEntityAbpCompatible 在 AddMeetingAndDescriptionFields 之前执行。
|
||||
/// 这是日志中 "no such table: T_Tasks" 错误的根因验证。
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void InitializeDatabase_MigrationOrder_Correct()
|
||||
{
|
||||
using var context = CreateContext();
|
||||
context.Database.Migrate();
|
||||
|
||||
var history = context.GetService<IHistoryRepository>();
|
||||
var appliedMigrations = history.GetAppliedMigrations()
|
||||
.Select(m => m.MigrationId)
|
||||
.ToList();
|
||||
|
||||
// 找到两个迁移的索引位置
|
||||
var abpCompatIndex = appliedMigrations.FindIndex(m => m.Contains("MakeTaskEntityAbpCompatible"));
|
||||
var meetingFieldsIndex = appliedMigrations.FindIndex(m => m.Contains("AddMeetingAndDescriptionFields"));
|
||||
|
||||
Assert.True(abpCompatIndex >= 0, "MakeTaskEntityAbpCompatible should be applied");
|
||||
Assert.True(meetingFieldsIndex >= 0, "AddMeetingAndDescriptionFields should be applied");
|
||||
// MakeTaskEntityAbpCompatible 必须在 AddMeetingAndDescriptionFields 之前
|
||||
Assert.True(abpCompatIndex < meetingFieldsIndex,
|
||||
$"MakeTaskEntityAbpCompatible (index {abpCompatIndex}) must be before AddMeetingAndDescriptionFields (index {meetingFieldsIndex})");
|
||||
}
|
||||
|
||||
// ========== 辅助方法 ==========
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
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())
|
||||
{
|
||||
columns.Add(reader.GetString(1)); // name 列是第 2 列 (index 1)
|
||||
}
|
||||
return columns;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
<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.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>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\Hua.Todo.Application\Hua.Todo.Application.csproj" />
|
||||
<ProjectReference Include="..\..\src\Hua.Todo.Core\Hua.Todo.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
Reference in New Issue
Block a user