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