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

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

422 lines
15 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using Hua.Todo.Application.Repositories;
using Hua.Todo.Application.Services.CloudSync.Models;
using Hua.Todo.Application.Services.CloudSync.Services;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging.Abstractions;
using Hua.Todo.Core.Entities;
using Xunit;
namespace Hua.Todo.Host.Tests;
/// <summary>
/// CloudTaskSyncService SQLite 集成测试(验证 UNIQUE 约束不会在生产环境中触发)。
/// </summary>
public class CloudTaskSyncServiceSqliteTests : IDisposable
{
private readonly SqliteConnection _connection;
private readonly TodoDbContext _dbContext;
private readonly CloudTaskSyncService _service;
private readonly Guid _testUserId;
public CloudTaskSyncServiceSqliteTests()
{
_testUserId = Guid.NewGuid();
// 使用共享缓存的 SQLite 内存数据库
_connection = new SqliteConnection("Data Source=CloudTaskSyncTests;Mode=Memory;Cache=Shared");
_connection.Open();
var options = new DbContextOptionsBuilder<TodoDbContext>()
.UseSqlite(_connection)
.Options;
_dbContext = new TodoDbContext(options);
_dbContext.Database.EnsureCreated();
// 创建测试用户(满足 UserId 外键约束)
_dbContext.Users.Add(new UserEntity
{
Id = _testUserId,
UserName = "test_user",
PasswordHash = "hash",
PasswordSalt = "salt",
Role = "User"
});
_dbContext.SaveChanges();
_service = new CloudTaskSyncService(_dbContext, NullLogger<CloudTaskSyncService>.Instance);
}
public void Dispose()
{
_dbContext.Dispose();
_connection.Dispose();
}
/// <summary>
/// 复现用户报告的 Bug:同一批次中同步父子任务(均有已知 IdlastModificationTime 为 null)。
/// 期望:首次同步成功创建两个任务。
/// </summary>
[Fact]
public async Task SyncAsync_ParentChildWithNullTime_FirstSync_Succeeds()
{
var parentId = Guid.NewGuid();
var childId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = childId,
Title = "子任务",
Priority = TaskPriority.Medium,
Code = "2",
ParentTaskId = parentId,
LastModificationTime = null
},
new CloudTaskUpsert
{
Id = parentId,
Title = "父任务",
Priority = TaskPriority.High,
Code = "1",
ParentTaskId = null,
LastModificationTime = null
}
}
};
var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, response.Tasks.Count);
var parent = response.Tasks.First(t => t.Title == "父任务");
Assert.Null(parent.ParentTaskId);
var child = response.Tasks.First(t => t.Title == "子任务");
Assert.Equal(parentId, child.ParentTaskId);
}
/// <summary>
/// 复现 Bug 场景:首次同步成功后再用相同数据重新同步(模拟客户端重试)。
/// lastModificationTime 为 null 时 LWW 应跳过更新,不应抛出 UNIQUE 约束。
/// </summary>
[Fact]
public async Task SyncAsync_ParentChildWithNullTime_Resync_NoUniqueViolation()
{
var parentId = Guid.NewGuid();
var childId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = childId, Title = "子任务", Priority = TaskPriority.Medium,
Code = "2", ParentTaskId = parentId, LastModificationTime = null
},
new CloudTaskUpsert
{
Id = parentId, Title = "父任务", Priority = TaskPriority.High,
Code = "1", ParentTaskId = null, LastModificationTime = null
}
}
};
// 首次同步
var firstResponse = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, firstResponse.Tasks.Count);
// 重新同步(同一 DbContext,模拟客户端重试)
var resyncResponse = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, resyncResponse.Tasks.Count);
}
/// <summary>
/// 测试多次重试同步不引发 UNIQUE 约束。
/// </summary>
[Fact]
public async Task SyncAsync_ParentChildWithNullTime_MultipleResyncs_NoUniqueViolation()
{
var parentId = Guid.NewGuid();
var childId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = childId, Title = "子任务", Priority = TaskPriority.Medium,
Code = "2", ParentTaskId = parentId, LastModificationTime = null
},
new CloudTaskUpsert
{
Id = parentId, Title = "父任务", Priority = TaskPriority.High,
Code = "1", ParentTaskId = null, LastModificationTime = null
}
}
};
for (int i = 0; i < 5; i++)
{
var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, response.Tasks.Count);
}
}
/// <summary>
/// 测试已有 DbContext 跟踪实体的场景下同步不会引发 UNIQUE 冲突。
/// 模拟场景:任务已通过其他 API 创建并仍被跟踪,然后通过 CloudSync 同步同一数据。
/// </summary>
[Fact]
public async Task SyncAsync_TaskExistsThenSynced_NoUniqueViolation()
{
var taskId = Guid.NewGuid();
var existingTask = new TaskEntity
{
Id = taskId,
UserId = _testUserId,
Title = "已有任务",
Priority = TaskPriority.Medium,
CreationTime = DateTime.UtcNow,
CreatorId = _testUserId,
LastModificationTime = DateTime.UtcNow,
LastModifierId = _testUserId
};
_dbContext.Tasks.Add(existingTask);
await _dbContext.SaveChangesAsync();
var syncRequest = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = taskId,
Title = "已有任务",
LastModificationTime = null
}
}
};
var response = await _service.SyncAsync(_testUserId, syncRequest, CancellationToken.None);
Assert.Single(response.Tasks);
Assert.Equal("已有任务", response.Tasks.First().Title);
}
/// <summary>
/// 复现用户 Bug 的核心场景:使用独立 DbContext 模拟跨请求重试。
/// Context A 创建任务并提交,Context B 再次同步相同数据时不应触发 UNIQUE 约束。
/// </summary>
[Fact]
public async Task SyncAsync_CrossContextResync_NoUniqueViolation()
{
var parentId = Guid.NewGuid();
var childId = Guid.NewGuid();
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = childId, Title = "子任务", Priority = TaskPriority.Medium,
Code = "2", ParentTaskId = parentId, LastModificationTime = null
},
new CloudTaskUpsert
{
Id = parentId, Title = "父任务", Priority = TaskPriority.High,
Code = "1", ParentTaskId = null, LastModificationTime = null
}
}
};
// Context A:首次同步
var firstResponse = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, firstResponse.Tasks.Count);
// Context B:使用全新 DbContext 重新同步(模拟另一个请求)
var options = new DbContextOptionsBuilder<TodoDbContext>()
.UseSqlite(_connection)
.Options;
using var dbContextB = new TodoDbContext(options);
var serviceB = new CloudTaskSyncService(dbContextB, NullLogger<CloudTaskSyncService>.Instance);
var resyncResponse = await serviceB.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, resyncResponse.Tasks.Count);
}
/// <summary>
/// 使用报错场景中的精确 UUID 复现 Bug:
/// 父子任务在同一批次中同步,lastModificationTime 均为 null。
/// </summary>
[Fact]
public async Task SyncAsync_ExactBugReportUuids_NoUniqueViolation()
{
// 使用报错日志中的精确 UUID
var parentId = Guid.Parse("6afeea6f-296f-4da1-a1e1-279e818f932c");
var childId = Guid.Parse("412290d0-01b7-4348-af2b-a6abb55dd580");
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = childId,
Title = "测试",
Priority = (TaskPriority)1,
IsCompleted = false,
Code = "2",
ParentTaskId = parentId,
LastModificationTime = null
},
new CloudTaskUpsert
{
Id = parentId,
Title = "测试",
Priority = (TaskPriority)1,
IsCompleted = false,
Code = "1",
ParentTaskId = null,
LastModificationTime = null
}
}
};
// 首次同步
var response1 = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, response1.Tasks.Count);
// 使用全新 DbContext 重新同步(模拟跨请求重试)
var options = new DbContextOptionsBuilder<TodoDbContext>()
.UseSqlite(_connection)
.Options;
using var dbContextB = new TodoDbContext(options);
var serviceB = new CloudTaskSyncService(dbContextB, NullLogger<CloudTaskSyncService>.Instance);
// 重试同步 - 不应抛出 UNIQUE 约束
var response2 = await serviceB.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Equal(2, response2.Tasks.Count);
}
/// <summary>
/// 测试同一次同步请求中,父任务已存在于 DB 但 lastModificationTime 为 null 的重同步场景。
/// </summary>
[Fact]
public async Task SyncAsync_ExistingTaskWithNullTime_NoUniqueViolation()
{
// 使用精确 UUID
var parentId = Guid.Parse("6afeea6f-296f-4da1-a1e1-279e818f932c");
var childId = Guid.Parse("412290d0-01b7-4348-af2b-a6abb55dd580");
// 先创建一个已存在的父任务(模拟前置同步已完成)
var existingParent = new TaskEntity
{
Id = parentId,
UserId = _testUserId,
Title = "测试",
Priority = (TaskPriority)1,
IsCompleted = false,
Code = "1",
ParentTaskId = null,
CreationTime = DateTime.UtcNow,
CreatorId = _testUserId,
LastModificationTime = DateTime.UtcNow,
LastModifierId = _testUserId
};
_dbContext.Tasks.Add(existingParent);
await _dbContext.SaveChangesAsync();
// 同步请求(lastModificationTime 为 null,应被 LWW 跳过)
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = parentId,
Title = "测试",
Priority = (TaskPriority)1,
IsCompleted = false,
Code = "1",
ParentTaskId = null,
LastModificationTime = null // 🔑 关键:null 时间戳
}
}
};
// 不应抛出 UNIQUE 约束
var response = await _service.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Single(response.Tasks);
}
/// <summary>
/// 验证跨 UserId 同步相同 Id 不再触发 UNIQUE 约束。
/// 任务已存在于 DB(通过其他 UserId 创建),用不同 UserId 同步相同 Id,
/// 应被 ProcessUpsertAsync 的 DB 级别二次查重发现并安全跳过。
/// </summary>
[Fact]
public async Task SyncAsync_TaskExistsWithDifferentUserId_HandlesGracefully()
{
// 创建另一个用户的 DbContext
var otherUserId = Guid.NewGuid();
_dbContext.Users.Add(new UserEntity
{
Id = otherUserId,
UserName = "other_user",
PasswordHash = "hash",
PasswordSalt = "salt",
Role = "User"
});
await _dbContext.SaveChangesAsync();
// 使用报错 UUID 创建任务,但属于 otherUserId
var parentId = Guid.Parse("6afeea6f-296f-4da1-a1e1-279e818f932c");
_dbContext.Tasks.Add(new TaskEntity
{
Id = parentId,
UserId = otherUserId,
Title = "other user task",
Priority = (TaskPriority)1,
CreationTime = DateTime.UtcNow,
CreatorId = otherUserId,
LastModificationTime = DateTime.UtcNow,
LastModifierId = otherUserId
});
await _dbContext.SaveChangesAsync();
// 使用独立 DbContext 同步相同 Id(模拟跨请求)
var options = new DbContextOptionsBuilder<TodoDbContext>()
.UseSqlite(_connection)
.Options;
using var dbContextB = new TodoDbContext(options);
var serviceB = new CloudTaskSyncService(dbContextB, NullLogger<CloudTaskSyncService>.Instance);
var request = new SyncRequest
{
Upserts = new List<CloudTaskUpsert>
{
new CloudTaskUpsert
{
Id = parentId,
Title = "测试",
Priority = (TaskPriority)1,
IsCompleted = false,
Code = "1",
ParentTaskId = null,
LastModificationTime = null
}
}
};
// 应正常完成(不抛出 DbUpdateException),DB 级别二次查重发现已存在实体并跳过
// 注意:response.Tasks 为空,因为该任务属于 otherUserId,不属于 _testUserId
var response = await serviceB.SyncAsync(_testUserId, request, CancellationToken.None);
Assert.Empty(response.Tasks);
}
}