新增 [Eis.Tools (line 1)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Tools/Program.cs:1),支持 database init/reset/seed。
数据库维护、安全校验和事务逻辑位于 [DatabaseMaintenanceService.cs (line 1)](C:/Users/BI/Documents/EIS-dotnet/src/Eis.Infrastructure/Data/DatabaseMaintenanceService.cs:1)。 内置完整演示数据:1200 名考生、10800 条成绩、2404 条招生记录。 删除旧 import-test-data.mjs 和 reset-dev-database.mjs 运行入口。 Docker 镜像同时包含 Web 和 /app/tools/Eis.Tools.dll。 [package.json (line 11)](C:/Users/BI/Documents/EIS-dotnet/package.json:11) 原数据库命令已改为调用 .NET。 新增统一发布脚本:[publish.ps1 (line 1)](C:/Users/BI/Documents/EIS-dotnet/scripts/publish.ps1:1)。
This commit is contained in:
@@ -0,0 +1,134 @@
|
||||
using Eis.Infrastructure.Data;
|
||||
using Microsoft.Data.Sqlite;
|
||||
|
||||
namespace Eis.Infrastructure.Tests.Data;
|
||||
|
||||
public sealed class DatabaseMaintenanceServiceTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SeedAndReset_UseValidatedReplacementAndCreateBackups()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), $"eis-tools-{Guid.NewGuid():N}");
|
||||
var path = Path.Combine(root, "eis.sqlite");
|
||||
Directory.CreateDirectory(root);
|
||||
try
|
||||
{
|
||||
var service = new DatabaseMaintenanceService(DatabaseOptions.CreateSqlite(path));
|
||||
await service.ExecuteAsync(
|
||||
DatabaseMaintenanceMode.Init,
|
||||
null,
|
||||
force: false,
|
||||
dryRun: false,
|
||||
CancellationToken.None);
|
||||
|
||||
var seed = await service.ExecuteAsync(
|
||||
DatabaseMaintenanceMode.Seed,
|
||||
path,
|
||||
force: false,
|
||||
dryRun: false,
|
||||
CancellationToken.None);
|
||||
Assert.NotNull(seed.BackupPath);
|
||||
Assert.True(File.Exists(seed.BackupPath));
|
||||
Assert.Equal(1200, seed.Summary?.Candidates);
|
||||
Assert.Equal(10800, seed.Summary?.Results);
|
||||
Assert.Equal(1200, await ScalarAsync(path, "SELECT COUNT(*) FROM candidate_profiles"));
|
||||
Assert.Equal(10800, await ScalarAsync(path, "SELECT COUNT(*) FROM results"));
|
||||
|
||||
var reset = await service.ExecuteAsync(
|
||||
DatabaseMaintenanceMode.Reset,
|
||||
path,
|
||||
force: false,
|
||||
dryRun: false,
|
||||
CancellationToken.None);
|
||||
Assert.NotNull(reset.BackupPath);
|
||||
Assert.True(File.Exists(reset.BackupPath));
|
||||
Assert.Equal(0, await ScalarAsync(path, "SELECT COUNT(*) FROM candidate_profiles"));
|
||||
Assert.Equal(1, await ScalarAsync(path, "SELECT COUNT(*) FROM users"));
|
||||
Assert.Equal(20, await ScalarAsync(path,
|
||||
"SELECT schema_version FROM schema_metadata WHERE id = 1"));
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
if (Directory.Exists(root))
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DestructiveCommand_RequiresExactTargetConfirmation()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), $"eis-tools-confirm-{Guid.NewGuid():N}");
|
||||
var path = Path.Combine(root, "eis.sqlite");
|
||||
try
|
||||
{
|
||||
var service = new DatabaseMaintenanceService(DatabaseOptions.CreateSqlite(path));
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.ExecuteAsync(
|
||||
DatabaseMaintenanceMode.Seed,
|
||||
path + ".wrong",
|
||||
force: false,
|
||||
dryRun: false,
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Contains("--confirm-target", exception.Message);
|
||||
Assert.False(File.Exists(path));
|
||||
}
|
||||
finally
|
||||
{
|
||||
SqliteConnection.ClearAllPools();
|
||||
if (Directory.Exists(root))
|
||||
{
|
||||
Directory.Delete(root, recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DryRun_ReportsSeedWithoutWritingDatabase()
|
||||
{
|
||||
var root = Path.Combine(Path.GetTempPath(), $"eis-tools-dry-{Guid.NewGuid():N}");
|
||||
var path = Path.Combine(root, "eis.sqlite");
|
||||
var service = new DatabaseMaintenanceService(DatabaseOptions.CreateSqlite(path));
|
||||
|
||||
var result = await service.ExecuteAsync(
|
||||
DatabaseMaintenanceMode.Seed,
|
||||
null,
|
||||
force: false,
|
||||
dryRun: true,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.True(result.DryRun);
|
||||
Assert.Equal(1200, result.Summary?.Candidates);
|
||||
Assert.False(File.Exists(path));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MySqlSystemDatabase_IsRejectedBeforeConnecting()
|
||||
{
|
||||
var options = DatabaseOptions.CreateMySql(
|
||||
"Server=127.0.0.1;User ID=test;Database=mysql;Connection Timeout=1");
|
||||
var service = new DatabaseMaintenanceService(options);
|
||||
|
||||
var exception = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
service.ExecuteAsync(
|
||||
DatabaseMaintenanceMode.Init,
|
||||
null,
|
||||
force: false,
|
||||
dryRun: true,
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Contains("系统数据库", exception.Message);
|
||||
}
|
||||
|
||||
private static async Task<long> ScalarAsync(string path, string sql)
|
||||
{
|
||||
await using var connection = new SqliteConnection($"Data Source={path};Pooling=False");
|
||||
await connection.OpenAsync();
|
||||
await using var command = connection.CreateCommand();
|
||||
command.CommandText = sql;
|
||||
return Convert.ToInt64(await command.ExecuteScalarAsync());
|
||||
}
|
||||
}
|
||||
+11
-13
@@ -53,19 +53,17 @@ assert.match(mysqlAdapterSource, /existingAppTables\.length && \(!hasSchemaMetad
|
||||
assert.match(mysqlAdapterSource, /\[\.\.\.mysqlTableNames\]\.reverse\(\)/, 'MySQL 半成品表应按外键依赖逆序清理');
|
||||
const serverSource = await readFile(resolve(root, 'server.mjs'), 'utf8');
|
||||
assert.doesNotMatch(serverSource, /src\/data\/seed\.mjs|createSeedDatabase/, '服务启动不得引用测试数据生成器');
|
||||
const testDataImportSource = await readFile(resolve(root, 'scripts', 'import-test-data.mjs'), 'utf8');
|
||||
assert.match(testDataImportSource, /loadEnvFile\(envPath\)/, '测试数据脚本应读取项目 .env');
|
||||
assert.match(testDataImportSource, /options\.has\('--mysql'\).*options\.has\('--sqlite'\)/, '测试数据脚本应支持显式选择 MySQL 或 SQLite');
|
||||
assert.doesNotMatch(testDataImportSource, /process\.env\.DATABASE_CLIENT\s*=\s*['"]sqlite['"]/, '测试数据脚本不得再强制使用 SQLite');
|
||||
assert.match(testDataImportSource, /nonEmpty\.length && !force/, 'MySQL 已有业务数据时应默认拒绝覆盖');
|
||||
assert.match(testDataImportSource, /SET FOREIGN_KEY_CHECKS = 0/, 'MySQL 样例数据替换应在受控外键环境中执行');
|
||||
assert.match(testDataImportSource, /beginTransaction\(\).*buildSeedOperations\(state\).*commit\(\)/s, 'MySQL 样例数据应在同一事务内清理并写入');
|
||||
assert.match(testDataImportSource, /initializeEmpty.*createBaseDatabase/s, '测试结束后应支持恢复空业务系统');
|
||||
assert.match(testDataImportSource, /recognizedTestData/, 'MySQL 初始化系统时应识别样例数据并保护非样例业务库');
|
||||
assert.match(testDataImportSource, /CURRENT_SCHEMA_VERSION/, '数据库导入脚本应复用统一的当前结构版本');
|
||||
const resetDatabaseSource = await readFile(resolve(root, 'scripts', 'reset-dev-database.mjs'), 'utf8');
|
||||
assert.match(resetDatabaseSource, /import\(['"]\.\/import-test-data\.mjs['"]\)/, '重置入口应复用统一的数据库初始化流程');
|
||||
assert.doesNotMatch(resetDatabaseSource, /DATABASE_CLIENT\s*=\s*['"]sqlite['"]/, '重置入口不得再强制使用 SQLite');
|
||||
const databaseToolSource = await readFile(resolve(root, 'src', 'Eis.Tools', 'Program.cs'), 'utf8');
|
||||
const databaseMaintenanceSource = await readFile(resolve(root, 'src', 'Eis.Infrastructure', 'Data', 'DatabaseMaintenanceService.cs'), 'utf8');
|
||||
assert.match(databaseToolSource, /EnvironmentFile\.Load\(applicationRoot\)/, '.NET 数据库工具应读取项目 .env');
|
||||
assert.match(databaseToolSource, /case "--sqlite".*case "--mysql"/s, '.NET 数据库工具应支持显式选择 MySQL 或 SQLite');
|
||||
assert.match(databaseToolSource, /--confirm-target/, '破坏性数据库命令必须精确确认目标');
|
||||
assert.match(databaseMaintenanceSource, /nonEmpty\.Count > 0 && !force/, 'MySQL 已有业务数据时应默认拒绝覆盖');
|
||||
assert.match(databaseMaintenanceSource, /SET FOREIGN_KEY_CHECKS = 0/, 'MySQL 样例数据替换应在受控外键环境中执行');
|
||||
assert.match(databaseMaintenanceSource, /BeginTransactionAsync.*ImportDemoSeedAsync.*CommitAsync/s, 'MySQL 样例数据应在同一事务内清理并写入');
|
||||
assert.match(databaseMaintenanceSource, /DatabaseMaintenanceMode\.Reset.*SeedBaseStateAsync/s, '测试结束后应支持恢复空业务系统');
|
||||
assert.match(databaseMaintenanceSource, /recognizedDemo/, 'MySQL 初始化系统时应识别样例数据并保护非样例业务库');
|
||||
assert.match(databaseMaintenanceSource, /SchemaVersion = 20/, '数据库导入工具应使用当前结构版本');
|
||||
const baseState = createBaseDatabase({ nowIso: () => new Date().toISOString(), hashPassword: password => `test-${password}` });
|
||||
assert.equal(baseState.meta.version, CURRENT_SCHEMA_VERSION, '空库初始状态必须使用当前结构版本');
|
||||
assert.equal(baseState.schools.length, 0, '正常首次建库不得预置学校');
|
||||
|
||||
Reference in New Issue
Block a user