| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546 |
- 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;
- /// <summary>
- /// S8-TENANT-ONLY-BATCH6:异常 / 工作流 / 主数据侧收敛为租户级作用域。
- ///
- /// <para><b>本批要消灭的断点</b>:Batch 5 之后规则运行策略的 <c>factory_id</c> 恒为 0,
- /// 于是自动建单产出的异常 <c>factory_id</c> 也是 0;而异常中心、任务流、卡死扫描
- /// 当时仍按 <c>(tenant_id, factory_id)</c> 查。结果是一个**完整闭环的静默失败**:
- /// 规则真实命中、异常真实落库、页面一条也看不到,且没有任何报错。</para>
- ///
- /// <para><b>本批还修掉了一个真实越权</b>(不是等价改写):主数据侧原来按
- /// <c>factory_ref_id</c> 划边界,而真库 <c>factory_ref_id = 1000</c>
- /// 同时属于租户 824585161322565 与 797403760988229——按它过滤会把另一租户的
- /// 613 名员工 / 210 个部门列进本租户的下拉框,并可被绑定成处理人、写进 responsible_dept_id。</para>
- ///
- /// <para>因此本文件的多数断言是**反向的**("不得再出现 factory 判据"):
- /// 这两类回归的共同特征是加回一个谓词就静默改变可见集合,正向断言抓不到。</para>
- /// </summary>
- 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"));
- /// <summary>只取可执行代码行:注释里为留档会复述旧写法,不应算违规。</summary>
- 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:可信作用域本身 ─────────────────
- /// <summary>T1:S8TrustedScope 只承载租户;工厂不再是它的一个维度。</summary>
- [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);
- }
- /// <summary>
- /// T2:LegacyFactoryId 恒等于平台默认哨兵 0,且**不是**作用域。
- /// 它只服务「还要往 factory_id 列写值」的旧代码;一旦有人把它当边界用,
- /// 得到的是「所有租户都相等」的常量,等于没有边界。
- /// </summary>
- [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);
- }
- /// <summary>T3:解析器只解析租户;工厂解析链(含 OrgType 常量)必须整体消失。</summary>
- [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:异常可见性 ─────────────────
- /// <summary>T4:异常查询 / 详情 / 筛选项一律不再接受 factoryId。</summary>
- [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)));
- }
- /// <summary>
- /// T5:异常表的查询谓词里不得再出现工厂等值。
- /// 这是本批的核心断点——留一条就等于「规则命中了但异常中心查不到」。
- /// </summary>
- [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);
- }
- /// <summary>
- /// T6:Controller 不得再把客户端 / 作用域的 factory 塞进查询 DTO。
- /// 留着 <c>q.FactoryId = ...</c> 就是给「以后有人在 Service 端把它读回去」留门。
- /// </summary>
- [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);
- }
- /// <summary>
- /// T7:查询 DTO 的 FactoryId 保留(老前端兼容),但必须是**死字段**。
- /// 保留字段本身没问题,被重新读起来才是问题。
- /// </summary>
- [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"));
- }
- /// <summary>T8:异常子资源的作用域校验入口收敛为 (exceptionId, tenantId)。</summary>
- [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:任务流 ─────────────────
- /// <summary>T9:全部按 Id 的流转动作都必须是 (id, tenantId, …),不得再带 factoryId。</summary>
- [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));
- }
- }
- /// <summary>
- /// T10:认领 / 转派<b>不再</b>自动改写责任部门。
- ///
- /// <para>原实现是 <c>Employee.Department(字符串) → DepartmentMaster</c>,
- /// 本批(S8-SYSUSER-ONLY-1)把处理人改成系统账号后这条链的第一跳没有了 ——
- /// <c>SysUser</c> 侧不存在指向 <c>DepartmentMaster</c> 的关系
- /// (<c>SysUser.OrgId</c> 指的是 <c>SysOrg</c> 公司/工厂树,是另一棵树)。</para>
- ///
- /// <para>原断言守的是「这条解析别按工厂做」。解析本身没了,守卫对象改为
- /// <b>别用 SysOrg 硬凑一个近似值把它复活</b> —— 责任部门由建单时确定,
- /// 之后只能由人显式修改,不再随处理人静默漂移。</para>
- /// </summary>
- [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);
- }
- /// <summary>T11:绑行查询只按 (Id, TenantId, !IsDeleted)。</summary>
- [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"));
- }
- /// <summary>T12:SLA 自动升级按异常自身租户调用,不再传工厂。</summary>
- [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:卡死扫描(本批最易静默失效处) ─────────────────
- /// <summary>
- /// T13:卡死扫描的枚举维度是租户;<c>factory_id > 0</c> 谓词必须消失。
- /// 留着它,本批之后(factory_id 恒 0)该扫描的候选集恒为空,
- /// 而 job 照常运行、日志照常打印——最难发现的那种失效。
- /// </summary>
- [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<long>>), list!.ReturnType);
- }
- /// <summary>T14:ScanAsync 只按租户分片。</summary>
- [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));
- }
- /// <summary>T15:(租户, 工厂) 复合作用域类型必须彻底消失,避免有人再拿它当分片键。</summary>
- [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:主数据(真实越权修复) ─────────────────
- /// <summary>
- /// T16:主数据三个下拉的边界必须是租户,工厂判据不得残留。
- ///
- /// <para>S8-SYSUSER-ONLY-1 之后人员下拉改列 <c>SysUser</c>(谓词写作
- /// <c>u.TenantId == scope.TenantId</c>),因此计数由 3 个 <c>x.</c> 变为
- /// 2 个 <c>x.</c> + 1 个 <c>u.</c>。断言总数仍是 3 —— 三个下拉,一个都不能缺边界。</para>
- /// </summary>
- [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"));
- }
- /// <summary>
- /// T17:操作员绑定已在 S8-SYSUSER-ONLY-1 整体退役,断言随之改为
- /// <b>该服务不得复活</b>。
- ///
- /// <para>原断言检查的是「员工硬边界从工厂收敛为租户」。那道边界现在没有对象可保护了 ——
- /// S8 不再把员工绑定成处理人。真正需要守的变成:这个能写 <c>EmployeeMaster</c> 的
- /// 写路径不要被重新引回来。删掉断言等于放弃这道守卫,因此改判而不是删除。</para>
- /// </summary>
- [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);
- }
- /// <summary>T18:异常列表 / 详情的部门名水合按租户查,不按工厂。</summary>
- [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);
- }
- /// <summary>
- /// T19:自动建单的部门校验按租户;「主数据在哪个工厂」这个三级解析必须整体消失。
- /// 它存在的唯一前提是「S8 运营工厂 ≠ 部门主数据工厂」,而工厂不再是作用域后该前提不成立。
- /// </summary>
- [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:主动提报 / 自动建单写入 ─────────────────
- /// <summary>
- /// T20:主动提报的可信作用域只解析租户;原来「租户未配机构就拒绝建单」的门禁一并去掉——
- /// 它保护不了任何东西(工厂不是隔离维度),却能让一个租户完全无法提报。
- /// </summary>
- [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);
- }
- /// <summary>T21:新建异常的 factory_id 一律盖章平台默认哨兵 0(既有行不重写)。</summary>
- [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"));
- }
- /// <summary>T22:自动建单入口不再要求工厂上下文,且不接受 factoryId 形参。</summary>
- [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:配置读取(平台默认哨兵必须存活) ─────────────────
- /// <summary>
- /// T23:配置读取判据收敛为「本租户 ∪ 平台默认」,工厂等值必须消失,
- /// 但 <c>(0,0)</c> 平台默认哨兵**不得被误删**——它不是工厂隔离,是平台基线。
- ///
- /// <para><c>S8SceneConfigService</c> 不在列:<c>ado_s8_scene_config</c> 本就没有平台默认行
- /// (实测 29 行全部是租户行,<c>tenant_id = 0</c> 的 0 行),它的读取一直是纯租户精确匹配。
- /// 把它一起断言"必须有哨兵"会逼出一个库里根本不存在的概念。</para>
- /// </summary>
- [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} 丢失了平台默认哨兵语义");
- }
- /// <summary>
- /// T24:覆盖优先级不得再按 <c>FactoryId</c> 降序判定。
- ///
- /// <para>这是本批最容易被写对一半的地方:谓词改成了租户,precedence 却留着
- /// <c>OrderByDescending(x => x.FactoryId)</c>。本批之后租户覆盖行的 factory_id 恒为 0,
- /// 与平台默认相等,排序退化 → 平台默认可能反超租户覆盖,
- /// 表现是「页面显示的生效配置与运行时真正用的不是同一条」。</para>
- /// </summary>
- [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);
- }
- /// <summary>T25:配置写入把 factory_id 盖章为兼容值 0,绝不写回一个真实工厂号。</summary>
- [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:迁移脚本 ─────────────────
- /// <summary>
- /// T26:唯一键在实体与迁移脚本两侧必须同步收敛,且**只**收敛唯一键。
- /// 只改一侧的后果不对称:只改实体 → CodeFirst 环境与迁移环境的 schema 分叉;
- /// 只改脚本 → 下次 EnableInitTable 启动会把旧唯一键加回来。
- /// </summary>
- [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);
- }
- /// <summary>
- /// T27:迁移必须先门禁后 DDL,且门禁失败要能在 <c>sys_db_migration_log.error_message</c> 里读出原因。
- /// 没有 preflight 的收紧唯一键 = 撞上历史重号时报一句索引错误,运维无从下手。
- /// </summary>
- [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);
- }
- /// <summary>
- /// T28:迁移刻意不碰的对象。
- /// <c>factory_id</c> 列、历史行的 factory_id 值、以及前三个已冻结脚本的归档表都必须原样保留——
- /// 本批移除的是隔离语义,不是 schema 美化,更不是把历史血缘抹平。
- /// </summary>
- [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;
- }
- }
|