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