4fe0b5a963
本次提交完成了项目核心基础架构升级: 1. 新增动态API中间件与权限控制系统,支持匿名/鉴权接口分离 2. 搭建云同步服务体系,包含认证、任务同步、安全策略等核心模块 3. 实现语音控制全链路,从STT/意图解析到命令执行 4. 新增任务类型、附件实体与相关仓储接口 5. 重构前端配置与代理规则,统一后端端口为5057 6. 新增多平台测试项目与CI脚本优化 7. 完善项目文档与代码注释规范 移除了旧版迁移文件与冗余代理配置,调整项目结构适配跨平台部署需求。
422 lines
15 KiB
C#
422 lines
15 KiB
C#
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);
|
||
}
|
||
}
|