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

移除了旧版迁移文件与冗余代理配置,调整项目结构适配跨平台部署需求。
2026-06-21 03:26:04 +08:00

680 lines
21 KiB
C#

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);
}
}