Files
Hua.Todo/test/Hua.Todo.Maui.Tests/DatabaseInitializationTests.cs
ShaoHua 4fe0b5a963 feat: 完成云同步、语音控制与多平台扩展基础架构搭建
本次提交完成了项目核心基础架构升级:
1. 新增动态API中间件与权限控制系统,支持匿名/鉴权接口分离
2. 搭建云同步服务体系,包含认证、任务同步、安全策略等核心模块
3. 实现语音控制全链路,从STT/意图解析到命令执行
4. 新增任务类型、附件实体与相关仓储接口
5. 重构前端配置与代理规则,统一后端端口为5057
6. 新增多平台测试项目与CI脚本优化
7. 完善项目文档与代码注释规范

移除了旧版迁移文件与冗余代理配置,调整项目结构适配跨平台部署需求。
2026-06-21 03:26:04 +08:00

315 lines
11 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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: EnsureCreatedfallback 路径)
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;
}
}