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; /// /// 数据库初始化集成测试,覆盖 MauiProgram.InitializeDatabase 的 Migrate → EnsureCreated 回退链路。 /// 对应日志中 "no such table: T_Tasks" 错误场景,验证迁移链完整性与恢复能力。 /// public class DatabaseInitializationTests : IDisposable { private readonly SqliteConnection _connection; private readonly DbContextOptions _options; public DatabaseInitializationTests() { _connection = new SqliteConnection("Data Source=:memory:"); _connection.Open(); _options = new DbContextOptionsBuilder() .UseSqlite(_connection) .Options; } public void Dispose() { _connection.Dispose(); } private TodoDbContext CreateContext() { return new TodoDbContext(_options); } /// /// 模拟 MauiProgram.InitializeDatabase 的 Migrate() 路径: /// 从零开始执行全部迁移,验证 T_Tasks 表存在。 /// 对应日志第 1-21 行:Migrate() 失败场景。 /// [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); } /// /// 验证迁移后所有核心表存在。 /// 对应日志第 23-27 行:查询 T_Tasks 失败场景。 /// [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); } /// /// 验证 T_Tasks 表包含日志中查询的所有列。 /// 对应日志第 24-26 行 SELECT 查询中引用的所有字段。 /// [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); } /// /// 模拟 MauiProgram.InitializeDatabase 的 fallback 路径: /// Migrate 失败 → 删除数据库 → EnsureCreated 重建。 /// 对应日志第 1 行 "Database initialization failed" 后的恢复流程。 /// [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() .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() .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); } } /// /// 验证 Migrate 是幂等的:第二次调用不会报错。 /// 模拟多次启动 MauiProgram 的场景。 /// [Fact] public void InitializeDatabase_Migrate_Idempotent() { using var context = CreateContext(); context.Database.Migrate(); // 第二次 Migrate 不应抛出异常 var exception = Record.Exception(() => context.Database.Migrate()); Assert.Null(exception); } /// /// 验证迁移后可以执行日志中失败的查询。 /// 对应日志第 23-27 行 SELECT 查询。 /// [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 == "测试"); } /// /// 验证迁移顺序正确:MakeTaskEntityAbpCompatible 在 AddMeetingAndDescriptionFields 之前执行。 /// 这是日志中 "no such table: T_Tasks" 错误的根因验证。 /// [Fact] public void InitializeDatabase_MigrationOrder_Correct() { using var context = CreateContext(); context.Database.Migrate(); var history = context.GetService(); 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 GetTableNames(TodoDbContext context) { return context.Database.SqlQueryRaw( "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 GetColumnNames(TodoDbContext context, string tableName) { var columns = new List(); 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; } }