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