using System.Reflection;
using Admin.NET.Plugin.AiDOP.Controllers.S8;
using Admin.NET.Plugin.AiDOP.Dto.S8;
using Admin.NET.Plugin.AiDOP.Infrastructure;
using Admin.NET.Plugin.AiDOP.Job;
using Admin.NET.Plugin.AiDOP.Service.S8;
using Admin.NET.Plugin.AiDOP.Service.S8.Rules;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S8;
///
/// S8-TENANT-ONLY-BATCH6:异常 / 工作流 / 主数据侧收敛为租户级作用域。
///
/// 本批要消灭的断点:Batch 5 之后规则运行策略的 factory_id 恒为 0,
/// 于是自动建单产出的异常 factory_id 也是 0;而异常中心、任务流、卡死扫描
/// 当时仍按 (tenant_id, factory_id) 查。结果是一个**完整闭环的静默失败**:
/// 规则真实命中、异常真实落库、页面一条也看不到,且没有任何报错。
///
/// 本批还修掉了一个真实越权(不是等价改写):主数据侧原来按
/// factory_ref_id 划边界,而真库 factory_ref_id = 1000
/// 同时属于租户 824585161322565 与 797403760988229——按它过滤会把另一租户的
/// 613 名员工 / 210 个部门列进本租户的下拉框,并可被绑定成处理人、写进 responsible_dept_id。
///
/// 因此本文件的多数断言是**反向的**("不得再出现 factory 判据"):
/// 这两类回归的共同特征是加回一个谓词就静默改变可见集合,正向断言抓不到。
///
public class S8TenantOnlyExceptionScopeTests
{
private const BindingFlags Instance =
BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic;
private static readonly string PluginRoot =
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
/// 只取可执行代码行:注释里为留档会复述旧写法,不应算违规。
private static string CodeOnly(string relativePath)
{
var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
return string.Join('\n', File.ReadAllLines(full)
.Where(l =>
{
var t = l.TrimStart();
return !t.StartsWith("///", StringComparison.Ordinal)
&& !t.StartsWith("//", StringComparison.Ordinal);
}));
}
// ───────────────── T1–T3:可信作用域本身 ─────────────────
/// T1:S8TrustedScope 只承载租户;工厂不再是它的一个维度。
[Fact]
public void T1_TrustedScope_CarriesTenantOnly()
{
Assert.Null(typeof(S8TrustedScope).GetProperty("FactoryId"));
var ctors = typeof(S8TrustedScope).GetConstructors();
Assert.All(ctors, c => Assert.DoesNotContain(
c.GetParameters(), p => p.Name!.Contains("factory", StringComparison.OrdinalIgnoreCase)));
var scope = new S8TrustedScope(838257186181189L);
Assert.Equal(838257186181189L, scope.TenantId);
}
///
/// T2:LegacyFactoryId 恒等于平台默认哨兵 0,且**不是**作用域。
/// 它只服务「还要往 factory_id 列写值」的旧代码;一旦有人把它当边界用,
/// 得到的是「所有租户都相等」的常量,等于没有边界。
///
[Fact]
public void T2_LegacyFactoryId_IsAlwaysPlatformSentinel()
{
Assert.Equal(S8ConfigScope.GlobalFactoryId, new S8TrustedScope(1L).LegacyFactoryId);
Assert.Equal(S8ConfigScope.GlobalFactoryId, new S8TrustedScope(838257186181189L).LegacyFactoryId);
Assert.Equal(0L, S8ConfigScope.GlobalFactoryId);
}
/// T3:解析器只解析租户;工厂解析链(含 OrgType 常量)必须整体消失。
[Fact]
public void T3_Resolver_HasNoFactoryResolutionPath()
{
var src = CodeOnly("Infrastructure/S8TrustedScopeResolver.cs");
Assert.DoesNotContain("ResolveFactoryIdAsync", src);
Assert.DoesNotContain("\"501\"", src);
Assert.DoesNotContain("SysOrg", src);
var resolve = typeof(S8TrustedScopeResolver).GetMethod(nameof(S8TrustedScopeResolver.ResolveAsync));
Assert.NotNull(resolve);
Assert.Empty(resolve!.GetParameters());
}
// ───────────────── T4–T8:异常可见性 ─────────────────
/// T4:异常查询 / 详情 / 筛选项一律不再接受 factoryId。
[Theory]
[InlineData(nameof(S8ExceptionService.GetFilterOptionsAsync))]
[InlineData(nameof(S8ExceptionService.GetDetailAsync))]
[InlineData(nameof(S8ExceptionService.SoftDeleteTestPrefixAsync))]
public void T4_ExceptionReadEntryPoints_TakeNoFactory(string method)
{
var overloads = typeof(S8ExceptionService)
.GetMethods(BindingFlags.Instance | BindingFlags.Public)
.Where(m => m.Name == method)
.ToList();
Assert.NotEmpty(overloads);
Assert.All(overloads, m => Assert.DoesNotContain(
m.GetParameters(), p => p.Name!.Contains("factory", StringComparison.OrdinalIgnoreCase)));
}
///
/// T5:异常表的查询谓词里不得再出现工厂等值。
/// 这是本批的核心断点——留一条就等于「规则命中了但异常中心查不到」。
///
[Theory]
[InlineData("Service/S8/S8ExceptionService.cs")]
[InlineData("Service/S8/S8TaskFlowService.cs")]
[InlineData("Service/S8/S8DetectionLogQueryService.cs")]
[InlineData("Service/S8/S8NotificationLogQueryService.cs")]
[InlineData("Service/S8/S8IssueLedgerService.cs")]
[InlineData("Service/S8/S8AlertAggregationService.cs")]
[InlineData("Service/S8/S8DecisionService.cs")]
public void T5_ExceptionQueries_HaveNoFactoryPredicate(string src)
{
var code = CodeOnly(src);
Assert.DoesNotContain("FactoryId == factoryId", code);
Assert.DoesNotContain("FactoryId == q.FactoryId", code);
Assert.DoesNotContain("FactoryId == scope.FactoryId", code);
}
///
/// T6:Controller 不得再把客户端 / 作用域的 factory 塞进查询 DTO。
/// 留着 q.FactoryId = ... 就是给「以后有人在 Service 端把它读回去」留门。
///
[Theory]
[InlineData("Controllers/S8/AdoS8ExceptionsController.cs")]
[InlineData("Controllers/S8/AdoS8ConfigDetectionLogsController.cs")]
[InlineData("Controllers/S8/AdoS8ConfigNotificationLogsController.cs")]
[InlineData("Controllers/S8/AdoS8IssueLedgerController.cs")]
[InlineData("Controllers/S8/AdoS8AlertAggregationController.cs")]
public void T6_Controllers_DoNotStampFactoryOntoQuery(string src)
{
var code = CodeOnly(src);
Assert.DoesNotContain("q.FactoryId = ", code);
Assert.DoesNotContain("scope.FactoryId", code);
}
///
/// T7:查询 DTO 的 FactoryId 保留(老前端兼容),但必须是**死字段**。
/// 保留字段本身没问题,被重新读起来才是问题。
///
[Fact]
public void T7_QueryDtoFactoryField_SurvivesButIsInert()
{
Assert.NotNull(typeof(AdoS8ExceptionQueryDto).GetProperty("FactoryId"));
Assert.NotNull(typeof(AdoS8ExceptionQueryDto).GetProperty("TenantId"));
// Service 侧不得读回来。
Assert.DoesNotContain("q.FactoryId", CodeOnly("Service/S8/S8ExceptionService.cs"));
}
/// T8:异常子资源的作用域校验入口收敛为 (exceptionId, tenantId)。
[Fact]
public void T8_ExceptionScopeGuard_IsTenantOnly()
{
Assert.NotNull(typeof(S8DecisionService).GetMethod(
nameof(S8DecisionService.IsExceptionInScopeAsync), [typeof(long), typeof(long)]));
Assert.Null(typeof(S8DecisionService).GetMethod(
nameof(S8DecisionService.IsExceptionInScopeAsync), [typeof(long), typeof(long), typeof(long)]));
}
// ───────────────── T9–T12:任务流 ─────────────────
/// T9:全部按 Id 的流转动作都必须是 (id, tenantId, …),不得再带 factoryId。
[Theory]
[InlineData(nameof(S8TaskFlowService.ClaimAsync))]
[InlineData(nameof(S8TaskFlowService.TransferAsync))]
[InlineData(nameof(S8TaskFlowService.StartProgressAsync))]
[InlineData(nameof(S8TaskFlowService.UpgradeAsync))]
[InlineData(nameof(S8TaskFlowService.RejectAsync))]
[InlineData(nameof(S8TaskFlowService.SubmitVerificationAsync))]
[InlineData(nameof(S8TaskFlowService.ApproveVerificationAsync))]
[InlineData(nameof(S8TaskFlowService.RejectVerificationAsync))]
[InlineData(nameof(S8TaskFlowService.CommentAsync))]
public void T9_TaskFlowActions_TakeIdAndTenantOnly(string method)
{
var overloads = typeof(S8TaskFlowService)
.GetMethods(BindingFlags.Instance | BindingFlags.Public)
.Where(m => m.Name == method)
.ToList();
Assert.NotEmpty(overloads);
foreach (var m in overloads)
{
var ps = m.GetParameters();
Assert.Equal(typeof(long), ps[0].ParameterType); // id
Assert.Equal(typeof(long), ps[1].ParameterType); // tenantId
Assert.Equal("tenantId", ps[1].Name);
Assert.DoesNotContain(ps, p =>
p.Name!.Contains("factory", StringComparison.OrdinalIgnoreCase));
}
}
///
/// T10:认领 / 转派不再自动改写责任部门。
///
/// 原实现是 Employee.Department(字符串) → DepartmentMaster,
/// 本批(S8-SYSUSER-ONLY-1)把处理人改成系统账号后这条链的第一跳没有了 ——
/// SysUser 侧不存在指向 DepartmentMaster 的关系
/// (SysUser.OrgId 指的是 SysOrg 公司/工厂树,是另一棵树)。
///
/// 原断言守的是「这条解析别按工厂做」。解析本身没了,守卫对象改为
/// 别用 SysOrg 硬凑一个近似值把它复活 —— 责任部门由建单时确定,
/// 之后只能由人显式修改,不再随处理人静默漂移。
///
[Fact]
public void T10_ResponsibleDeptIsNotSilentlyRewrittenOnAssignment()
{
Assert.Null(typeof(S8TaskFlowService).GetMethod("ResolveEmployeeResponsibleDeptIdAsync", Instance));
var code = CodeOnly("Service/S8/S8TaskFlowService.cs");
Assert.DoesNotContain("ResponsibleDeptId =", code);
Assert.DoesNotContain("SysOrg", code);
}
/// T11:绑行查询只按 (Id, TenantId, !IsDeleted)。
[Fact]
public void T11_TaskFlowLoad_BindsByTenantOnly()
{
var load = typeof(S8TaskFlowService).GetMethod("LoadAsync", Instance);
Assert.NotNull(load);
Assert.Equal(new[] { "id", "tenantId" }, load!.GetParameters().Select(p => p.Name).ToArray());
Assert.Contains("x.Id == id && x.TenantId == tenantId && !x.IsDeleted",
CodeOnly("Service/S8/S8TaskFlowService.cs"));
}
/// T12:SLA 自动升级按异常自身租户调用,不再传工厂。
[Fact]
public void T12_AutoEscalation_CallsUpgradeWithTenantOnly()
{
var code = CodeOnly("Service/S8/S8TimeoutAutoEscalationService.cs");
Assert.Contains("_taskFlow.UpgradeAsync(e.Id, e.TenantId, remark)", code);
Assert.DoesNotContain("e.FactoryId", code);
}
// ───────────────── T13–T15:卡死扫描(本批最易静默失效处) ─────────────────
///
/// T13:卡死扫描的枚举维度是租户;factory_id > 0 谓词必须消失。
/// 留着它,本批之后(factory_id 恒 0)该扫描的候选集恒为空,
/// 而 job 照常运行、日志照常打印——最难发现的那种失效。
///
[Fact]
public void T13_ActiveFlowScan_EnumeratesTenants_AndDropsFactoryFilter()
{
var code = CodeOnly("Service/S8/S8ActiveFlowWatchService.cs");
Assert.Contains("SELECT DISTINCT e.tenant_id", code);
Assert.DoesNotContain("e.factory_id > 0", code);
Assert.DoesNotContain("e.factory_id AS FactoryId", code);
var list = typeof(S8ActiveFlowWatchService).GetMethod(
nameof(S8ActiveFlowWatchService.ListActiveTenantsAsync));
Assert.NotNull(list);
Assert.Equal(typeof(Task>), list!.ReturnType);
}
/// T14:ScanAsync 只按租户分片。
[Fact]
public void T14_ActiveFlowScan_ScansByTenant()
{
var scan = typeof(S8ActiveFlowWatchService).GetMethod(nameof(S8ActiveFlowWatchService.ScanAsync));
Assert.NotNull(scan);
Assert.Equal("tenantId", scan!.GetParameters()[0].Name);
Assert.DoesNotContain(scan.GetParameters(), p =>
p.Name!.Contains("factory", StringComparison.OrdinalIgnoreCase));
}
/// T15:(租户, 工厂) 复合作用域类型必须彻底消失,避免有人再拿它当分片键。
[Fact]
public void T15_TenantFactoryScopeType_IsRemoved()
{
Assert.Null(typeof(S8ActiveFlowWatchService).Assembly
.GetType("Admin.NET.Plugin.AiDOP.Service.S8.S8TenantFactoryScope"));
Assert.DoesNotContain("S8TenantFactoryScope", CodeOnly("Job/S8ActiveFlowStuckScanJob.cs"));
}
// ───────────────── T16–T19:主数据(真实越权修复) ─────────────────
///
/// T16:主数据三个下拉的边界必须是租户,工厂判据不得残留。
///
/// S8-SYSUSER-ONLY-1 之后人员下拉改列 SysUser(谓词写作
/// u.TenantId == scope.TenantId),因此计数由 3 个 x. 变为
/// 2 个 x. + 1 个 u.。断言总数仍是 3 —— 三个下拉,一个都不能缺边界。
///
[Fact]
public void T16_MasterDataDropdowns_AreTenantBound()
{
var code = CodeOnly("Service/S8/S8MasterDataAdapter.cs");
Assert.DoesNotContain("FactoryRefId", code);
Assert.Equal(3,
CountOccurrences(code, "x.TenantId == scope.TenantId")
+ CountOccurrences(code, "u.TenantId == scope.TenantId"));
}
///
/// T17:操作员绑定已在 S8-SYSUSER-ONLY-1 整体退役,断言随之改为
/// 该服务不得复活。
///
/// 原断言检查的是「员工硬边界从工厂收敛为租户」。那道边界现在没有对象可保护了 ——
/// S8 不再把员工绑定成处理人。真正需要守的变成:这个能写 EmployeeMaster 的
/// 写路径不要被重新引回来。删掉断言等于放弃这道守卫,因此改判而不是删除。
///
[Fact]
public void T17_OperatorBinding_IsRetiredNotResurrected()
{
Assert.False(File.Exists(Path.Combine(PluginRoot, "Service", "S8", "S8OperatorBindingService.cs")));
var code = CodeOnly("Controllers/S8/AdoS8ConfigBindingsController.cs");
Assert.Contains("Status410Gone", code);
Assert.DoesNotContain("EmployeeMaster", code);
}
/// T18:异常列表 / 详情的部门名水合按租户查,不按工厂。
[Fact]
public void T18_DepartmentHydration_IsTenantBound()
{
var code = CodeOnly("Service/S8/S8ExceptionService.cs");
Assert.Contains("deptIds.Contains(x.Id) && x.TenantId == tenantId", code);
Assert.DoesNotContain("x.FactoryRefId == factoryId", code);
}
///
/// T19:自动建单的部门校验按租户;「主数据在哪个工厂」这个三级解析必须整体消失。
/// 它存在的唯一前提是「S8 运营工厂 ≠ 部门主数据工厂」,而工厂不再是作用域后该前提不成立。
///
[Fact]
public void T19_AutoWatchDeptResolution_DropsMasterDataFactoryIndirection()
{
var code = CodeOnly("Service/S8/S8ManualReportService.cs");
Assert.DoesNotContain("ResolveDepartmentFactoryRefIdAsync", code);
Assert.DoesNotContain("ValidateDeptForMasterDataFactoryAsync", code);
Assert.DoesNotContain("_masterDataOptions.DepartmentFactoryRefId", code);
Assert.Contains("ValidateDeptInTenantAsync", code);
Assert.Contains("x.Department == UnassignedDepartmentCode && x.TenantId == tenantId", code);
}
// ───────────────── T20–T22:主动提报 / 自动建单写入 ─────────────────
///
/// T20:主动提报的可信作用域只解析租户;原来「租户未配机构就拒绝建单」的门禁一并去掉——
/// 它保护不了任何东西(工厂不是隔离维度),却能让一个租户完全无法提报。
///
[Fact]
public void T20_ManualReport_ResolvesTenantWithoutOrgPrecondition()
{
var code = CodeOnly("Service/S8/S8ManualReportService.cs");
Assert.Contains("private long ResolveTrustedTenantId()", code);
Assert.DoesNotContain("SysTenant>()", code);
Assert.DoesNotContain("当前租户尚未配置所属机构", code);
}
/// T21:新建异常的 factory_id 一律盖章平台默认哨兵 0(既有行不重写)。
[Fact]
public void T21_NewExceptions_StampCompatibilityFactoryZero()
{
var code = CodeOnly("Service/S8/S8ManualReportService.cs");
Assert.DoesNotContain("FactoryId = factoryId", code);
Assert.DoesNotContain("FactoryId = trustedScope.FactoryId", code);
Assert.Equal(3, CountOccurrences(code, "FactoryId = S8ConfigScope.GlobalFactoryId"));
}
/// T22:自动建单入口不再要求工厂上下文,且不接受 factoryId 形参。
[Fact]
public void T22_AutoCreationEntryPoints_RequireTenantOnly()
{
foreach (var name in new[]
{
nameof(S8ManualReportService.CreateFromWatchAsync),
nameof(S8ManualReportService.CreateFromHitAsync),
})
{
var m = typeof(S8ManualReportService).GetMethods(BindingFlags.Instance | BindingFlags.Public)
.Single(x => x.Name == name);
Assert.Equal("tenantId", m.GetParameters()[0].Name);
Assert.DoesNotContain(m.GetParameters(), p =>
p.Name!.Contains("factory", StringComparison.OrdinalIgnoreCase));
}
Assert.DoesNotContain("自动建单缺失有效租户或工厂上下文",
CodeOnly("Service/S8/S8ManualReportService.cs"));
}
// ───────────────── T23–T25:配置读取(平台默认哨兵必须存活) ─────────────────
///
/// T23:配置读取判据收敛为「本租户 ∪ 平台默认」,工厂等值必须消失,
/// 但 (0,0) 平台默认哨兵**不得被误删**——它不是工厂隔离,是平台基线。
///
/// S8SceneConfigService 不在列:ado_s8_scene_config 本就没有平台默认行
/// (实测 29 行全部是租户行,tenant_id = 0 的 0 行),它的读取一直是纯租户精确匹配。
/// 把它一起断言"必须有哨兵"会逼出一个库里根本不存在的概念。
///
[Theory]
[InlineData("Service/S8/S8ExceptionTypeService.cs")]
[InlineData("Service/S8/S8NotificationLayerService.cs")]
[InlineData("Service/S8/S8RoleConfigService.cs")]
[InlineData("Service/S8/S8DashboardCellConfigService.cs")]
[InlineData("Service/S8/S8KpiTargetConfigService.cs")]
public void T23_ConfigReads_AreTenantScoped_ButKeepPlatformSentinel(string src)
{
var code = CodeOnly(src);
Assert.DoesNotContain("x.FactoryId == factoryId", code);
Assert.DoesNotContain("x.FactoryId == scope.FactoryId", code);
Assert.DoesNotContain("x.FactoryId == body.FactoryId", code);
Assert.DoesNotContain("x.FactoryId == e.FactoryId", code);
// 哨兵语义还在(IsGlobal / GlobalTenantId / GlobalFactoryId 至少出现其一)。
Assert.True(
code.Contains("S8ConfigScope.GlobalTenantId", StringComparison.Ordinal)
|| code.Contains("S8ConfigScope.IsGlobal", StringComparison.Ordinal)
|| code.Contains("S8ConfigScope.GlobalFactoryId", StringComparison.Ordinal),
$"{src} 丢失了平台默认哨兵语义");
}
///
/// T24:覆盖优先级不得再按 FactoryId 降序判定。
///
/// 这是本批最容易被写对一半的地方:谓词改成了租户,precedence 却留着
/// OrderByDescending(x => x.FactoryId)。本批之后租户覆盖行的 factory_id 恒为 0,
/// 与平台默认相等,排序退化 → 平台默认可能反超租户覆盖,
/// 表现是「页面显示的生效配置与运行时真正用的不是同一条」。
///
[Theory]
[InlineData("Service/S8/S8ExceptionTypeService.cs")]
[InlineData("Service/S8/S8RoleConfigService.cs")]
[InlineData("Service/S8/S8NotificationLayerService.cs")]
[InlineData("Service/S8/S8DashboardCellConfigService.cs")]
[InlineData("Service/S8/S8MonitorDictionaryService.cs")]
[InlineData("Service/S8/S8MonitoringService.cs")]
[InlineData("Service/S8/S8ManualReportService.cs")]
public void T24_ConfigPrecedence_NoLongerRanksByFactoryId(string src)
{
var code = CodeOnly(src);
Assert.DoesNotContain("OrderByDescending(x => x.FactoryId)", code);
Assert.DoesNotContain("OrderByDescending(t => t.FactoryId)", code);
}
/// T25:配置写入把 factory_id 盖章为兼容值 0,绝不写回一个真实工厂号。
[Theory]
[InlineData("Service/S8/S8ExceptionTypeService.cs")]
[InlineData("Service/S8/S8SceneConfigService.cs")]
[InlineData("Service/S8/S8NotificationLayerService.cs")]
[InlineData("Service/S8/S8RoleConfigService.cs")]
[InlineData("Service/S8/S8DashboardCellConfigService.cs")]
public void T25_ConfigWrites_StampCompatibilityFactory(string src)
{
var code = CodeOnly(src);
Assert.DoesNotContain("body.FactoryId = scope.FactoryId", code);
Assert.Contains("scope.LegacyFactoryId", code);
}
// ───────────────── T26–T28:迁移脚本 ─────────────────
///
/// T26:唯一键在实体与迁移脚本两侧必须同步收敛,且**只**收敛唯一键。
/// 只改一侧的后果不对称:只改实体 → CodeFirst 环境与迁移环境的 schema 分叉;
/// 只改脚本 → 下次 EnableInitTable 启动会把旧唯一键加回来。
///
[Fact]
public void T26_ExceptionUniqueKey_ConvergesInEntityAndMigration()
{
var entity = CodeOnly("Entity/S8/AdoS8Exception.cs");
Assert.Contains("\"uk_ado_s8_exception_tenant_code\", nameof(TenantId), OrderByType.Asc, nameof(ExceptionCode)", entity);
Assert.DoesNotContain("uk_ado_s8_exception_tenant_factory_code", entity);
var sql = ExecutableSql(MigrationSql());
Assert.Contains("ADD UNIQUE INDEX uk_ado_s8_exception_tenant_code (tenant_id, exception_code)", sql);
Assert.Contains("DROP INDEX uk_ado_s8_exception_tenant_factory_code", sql);
}
///
/// T27:迁移必须先门禁后 DDL,且门禁失败要能在 sys_db_migration_log.error_message 里读出原因。
/// 没有 preflight 的收紧唯一键 = 撞上历史重号时报一句索引错误,运维无从下手。
///
[Fact]
public void T27_Migration_GatesBeforeDdl()
{
var sql = ExecutableSql(MigrationSql());
var gate = sql.IndexOf("s8_tenant_only_BLOCKED_see_dupcode_table_1_0_494", StringComparison.Ordinal);
var ddl = sql.IndexOf("ADD UNIQUE INDEX uk_ado_s8_exception_tenant_code", StringComparison.Ordinal);
Assert.True(gate >= 0, "缺少 preflight 门禁");
Assert.True(ddl > gate, "门禁必须在任何 DDL 之前");
Assert.Contains("s8_tenant_only_POSTCHECK_FAILED_1_0_494", sql);
// AutoVersionUpdate 对全文做子串匹配拒绝自定义语句分隔符指令——连注释里出现都会被拦。
Assert.DoesNotContain("DELIMITER", MigrationSql(), StringComparison.OrdinalIgnoreCase);
}
///
/// T28:迁移刻意不碰的对象。
/// factory_id 列、历史行的 factory_id 值、以及前三个已冻结脚本的归档表都必须原样保留——
/// 本批移除的是隔离语义,不是 schema 美化,更不是把历史血缘抹平。
///
[Fact]
public void T28_Migration_DoesNotTouchOutOfScopeObjects()
{
// 只看可执行 SQL:脚本注释里会**明确列出"不动哪些对象"**,那正是我们想要的留档。
var sql = ExecutableSql(MigrationSql());
Assert.DoesNotContain("DROP COLUMN", sql);
Assert.DoesNotContain("UPDATE ado_s8_exception", sql);
Assert.DoesNotContain("ado_s8_exception_bak_1_0_487", sql);
Assert.DoesNotContain("ado_s8_exception_dedup_bak_1_0_493", sql);
Assert.DoesNotContain("ado_s8_scene_config", sql);
Assert.DoesNotContain("ado_s8_notification_layer", sql);
Assert.DoesNotContain("ado_s8_watch_rule", sql);
}
private static string ExecutableSql(string sql) =>
string.Join('\n', sql.Split('\n').Where(l => !l.TrimStart().StartsWith("--", StringComparison.Ordinal)));
private static string MigrationSql()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Web.Entry")))
dir = dir.Parent;
Assert.NotNull(dir);
var path = Path.Combine(dir!.FullName, "Admin.NET.Web.Entry", "UpdateScripts", "1.0.494.sql");
Assert.True(File.Exists(path), $"迁移脚本不存在,路径需同步更新:{path}");
return File.ReadAllText(path);
}
private static int CountOccurrences(string haystack, string needle)
{
var n = 0;
for (var i = haystack.IndexOf(needle, StringComparison.Ordinal);
i >= 0;
i = haystack.IndexOf(needle, i + needle.Length, StringComparison.Ordinal))
n++;
return n;
}
}