| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462 |
- using Admin.NET.Plugin.AiDOP.Const.S8;
- using Xunit;
- namespace Admin.NET.Plugin.AiDOP.Tests.S8;
- /// <summary>
- /// S8-RESPONSIBILITY-POOL-1:规则三责任池(处理 / 复核 / 升级)。
- ///
- /// <para><b>本批要挡住的</b>:角色与责任被重新混为一谈、复核人可以是池外的人、
- /// 页面选甲而审批任务发给乙、升级回落到某个角色、通知对象与责任池各说各话。
- /// 这些都是<b>静默</b>失败 —— 每一层都报成功,只有真实业务跑到那一步才会发现。</para>
- /// </summary>
- public class S8ResponsibilityPoolTests
- {
- private static readonly string PluginRoot =
- Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
- private static string Read(string relativePath)
- {
- var full = Path.Combine(PluginRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
- Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
- return File.ReadAllText(full);
- }
- /// <summary>去掉注释行,避免"注释里提到某个词"被当成实现证据。</summary>
- private static string CodeOnly(string relativePath) =>
- string.Join('\n', Read(relativePath).Split('\n').Where(l =>
- {
- var t = l.TrimStart();
- return !t.StartsWith("///", StringComparison.Ordinal) && !t.StartsWith("//", StringComparison.Ordinal);
- }));
- private static string PoolService() => CodeOnly("Service/S8/S8RuleResponsibilityPoolService.cs");
- private static string VerifierEligibility() => CodeOnly("Infrastructure/S8/S8VerifierEligibility.cs");
- private static string ReadinessGate() => CodeOnly("Service/S8/Rules/S8RuleReadinessGate.cs");
- private static string TaskFlow() => CodeOnly("Service/S8/S8TaskFlowService.cs");
- private static string ClosureHandler() => CodeOnly("Service/S8/ExceptionClosureBizHandler.cs");
- private static string Escalation() => CodeOnly("Service/S8/S8TimeoutAutoEscalationService.cs");
- private static string NotifyResolver() => CodeOnly("Service/S8/S8NotificationRecipientResolver.cs");
- // ══════════════════════════════ 目录与存储 ══════════════════════════════
- /// <summary>三种责任类型齐备,且每一种都有面向业务的文案(页面不出现技术枚举)。</summary>
- [Fact]
- public void Catalog_HasThreePoolsWithBusinessWording()
- {
- Assert.Equal(3, S8ResponsibilityCatalog.All.Count);
- Assert.Equal(
- new[] { S8ResponsibilityType.Handler, S8ResponsibilityType.Reviewer, S8ResponsibilityType.Escalation },
- S8ResponsibilityCatalog.All.OrderBy(x => x.OrderNo).Select(x => x.Code));
- foreach (var d in S8ResponsibilityCatalog.All)
- {
- Assert.False(string.IsNullOrWhiteSpace(d.DisplayName));
- Assert.False(string.IsNullOrWhiteSpace(d.Description));
- // 业务文案里不得出现技术术语
- foreach (var banned in new[] { "Role", "ROLE_", "ApproverType", "Pool", "SysUser" })
- Assert.DoesNotContain(banned, d.DisplayName + d.Description);
- }
- }
- /// <summary>
- /// T1/T2/T3 存储路由:处理池仍走存量表,复核 / 升级走新表。
- /// <para>这条断言锁的是「不为了表结构好看去迁已经稳定的处理池」这个决定 ——
- /// 迁它要动就绪门禁 / 认领资格 / 可见性收敛 / 通知解析四条已跑通的链路。</para>
- /// </summary>
- [Fact]
- public void Storage_HandlerStaysInLegacyTable_ReviewerAndEscalationInNewTable()
- {
- Assert.False(S8ResponsibilityCatalog.IsStoredInResponsibilityTable(S8ResponsibilityType.Handler));
- Assert.True(S8ResponsibilityCatalog.IsStoredInResponsibilityTable(S8ResponsibilityType.Reviewer));
- Assert.True(S8ResponsibilityCatalog.IsStoredInResponsibilityTable(S8ResponsibilityType.Escalation));
- var code = PoolService();
- // HANDLER 读写都路由到存量实现,不在本服务里复制第二份处理池逻辑
- Assert.Contains("_handlerReader.GetMemberIdsAsync", code);
- Assert.Contains("_handlerWriter.SetMembersAsync", code);
- }
- /// <summary>T1–T3 写入:三类共用同一个写入口,成员判据同源。</summary>
- [Fact]
- public void T1_T2_T3_AllThreePools_ShareOneWriteEntryAndOneValidator()
- {
- var code = PoolService();
- Assert.Contains("public async Task SetMembersAsync(S8TrustedScope scope, string ruleCode, string responsibilityType", code);
- Assert.Contains("_userScope.ValidateUsersAsync", code);
- // 未知类型不落库
- Assert.Contains("未知的责任类型", Read("Service/S8/S8RuleResponsibilityPoolService.cs"));
- }
- // ══════════════════════════════ 租户与账号 ══════════════════════════════
- /// <summary>
- /// T4/T5/T6 跨租户成员必须被拒;T7 停用账号同样被拒。
- /// <para>判据统一走 <c>S8UserScopeValidator</c>(同租户 + 启用),
- /// 且<b>跨租户与不存在共用同一文案</b>,不泄漏存在性。</para>
- /// </summary>
- [Fact]
- public void T4_T7_CrossTenantAndInactiveMembers_AreRejectedWithSameMessage()
- {
- var code = PoolService();
- Assert.Contains("var validated = await _userScope.ValidateUsersAsync(scope.TenantId, wanted);", code);
- Assert.Contains("throw new S8BizException(\"所选账号不存在或已停用\")", code);
- // 池自己不写用户查询 —— 各写一套判据正是"配得进池、运行时又被拒"的来源
- Assert.DoesNotContain("Queryable<SysUser>", code);
- }
- /// <summary>T8 工厂不参与责任池;T9 不出现员工主数据。</summary>
- [Theory]
- [InlineData("Service/S8/S8RuleResponsibilityPoolService.cs")]
- [InlineData("Entity/S8/AdoS8RuleResponsibilityUser.cs")]
- [InlineData("Infrastructure/S8/S8VerifierEligibility.cs")]
- public void T8_T9_NoFactoryNoEmployee(string relativePath)
- {
- var code = CodeOnly(relativePath);
- Assert.DoesNotContain("FactoryId", code);
- Assert.DoesNotContain("factory_id", code);
- Assert.DoesNotContain("EmployeeMaster", code);
- Assert.DoesNotContain("EmployeeId", code);
- }
- /// <summary>
- /// T26 租户隔离:<b>每一条</b>池查询都带 tenant 谓词。
- /// <para>读路径用 <c>tenantId</c> 参数、写路径用 <c>scope.TenantId</c>(服务端盖章),
- /// 两种写法都算数;缺任何一条就是跨租户读写。</para>
- /// </summary>
- [Fact]
- public void T26_EveryPoolQuery_CarriesTenantPredicate()
- {
- var code = PoolService();
- var queries = code.Split("AsQueryable()").Skip(1).ToList();
- Assert.NotEmpty(queries);
- foreach (var q in queries)
- {
- var head = q[..Math.Min(q.Length, 400)];
- Assert.True(head.Contains("x.TenantId == tenantId", StringComparison.Ordinal)
- || head.Contains("x.TenantId == scope.TenantId", StringComparison.Ordinal),
- $"存在没有租户谓词的池查询:{head[..Math.Min(head.Length, 160)]}");
- }
- }
- // ══════════════════════════════ 就绪门禁 ══════════════════════════════
- /// <summary>
- /// T10/T11/T12 三条判据由<b>各自的声明</b>驱动,互不捆绑。
- ///
- /// <para>拆分前处理池判据被写在部门分支的提前返回之后,事实上只对"必须配部门"的规则生效。
- /// 再叠加复核 / 升级两条判据只会把耦合放大成三重。</para>
- /// </summary>
- [Fact]
- public void T10_T11_T12_EachReadinessCheck_HasItsOwnDeclaration()
- {
- var code = ReadinessGate();
- Assert.Contains("if (policy.RequiresDepartmentDefaultsForEnable)", code);
- Assert.Contains("if (policy.RequiresHandlerPoolForEnable)", code);
- Assert.Contains("if (policy.RequiresVerification)", code);
- Assert.Contains("if (policy.SupportsTimeoutEscalation)", code);
- // 各自独立的原因码 —— 管理员必须知道缺的是哪个池
- Assert.Contains("HandlerPoolEmpty", code);
- Assert.Contains("ReviewerPoolEmpty", code);
- Assert.Contains("EscalationPoolEmpty", code);
- }
- /// <summary>
- /// 空池判据是「有没有<b>当前有效</b>的成员」,不是「表里有没有行」。
- /// <para>账号停用后那一行还在,但那个人已经不算数了。只数行数会放行一条实际上没人负责的规则。</para>
- /// </summary>
- [Fact]
- public void ReadinessGate_JudgesByValidMembers_NotRowCount()
- {
- var code = ReadinessGate();
- Assert.Contains("members.Any(m => m.Valid)", code);
- // 「一个都没配」与「配了但都失效」分开报:管理员的下一步动作完全不同
- Assert.Contains("members.Count == 0 ? emptyMessage : allInvalidMessage", code);
- }
- /// <summary>Rule 01 显式声明三条判据;处理池那条与拆分前行为一致,不顺带改变启用条件。</summary>
- [Fact]
- public void Rule01_DeclaresAllThreeResponsibilities()
- {
- var def = Read("Service/S8/Rules/Definitions/S8PurchaseDeliveryRuleDefinitions.cs");
- Assert.Contains("RequiresHandlerPoolForEnable = true", def);
- Assert.Contains("RequiresVerification = true", def);
- Assert.Contains("SupportsTimeoutEscalation = true", def);
- }
- /// <summary>
- /// T23 空池<b>绝不</b>回落到角色。
- /// <para>回落会让「我没给这条规则配人」与「有人在负责」同时成立 —— 最难查的一类错配。</para>
- /// </summary>
- [Fact]
- public void T23_EmptyPool_NeverFallsBackToAnyRole()
- {
- foreach (var code in new[] { ReadinessGate(), Escalation(), VerifierEligibility() })
- {
- Assert.DoesNotContain("ROLE_QC", code);
- Assert.DoesNotContain("EscalateRoleCode", code);
- Assert.DoesNotContain("escalate_role_code", code);
- }
- }
- /// <summary>T24 L1/L2/L3 与虚拟主管层级不得成为当前 authority。</summary>
- [Fact]
- public void T24_NoVirtualHierarchyAsCurrentAuthority()
- {
- foreach (var rel in new[]
- {
- "Service/S8/S8RuleResponsibilityPoolService.cs",
- "Infrastructure/S8/S8VerifierEligibility.cs",
- "Service/S8/S8TimeoutAutoEscalationService.cs",
- "Const/S8/S8ResponsibilityCatalog.cs",
- })
- {
- var code = CodeOnly(rel);
- foreach (var banned in new[] { "L1_OPERATOR", "L2_MANAGER", "L3_DIRECTOR", "DepartmentLeader", "DirectorId", "ManagerUserId" })
- Assert.DoesNotContain(banned, code);
- }
- }
- // ══════════════════════════════ 复核契约 ══════════════════════════════
- /// <summary>
- /// T13/T14/T15 复核人资格 = 复核池 ∩ 审核权限 ∩ 可用账号,三者缺一不可。
- /// </summary>
- [Fact]
- public void T13_T14_T15_VerifierMustSatisfyAllThreeConditions()
- {
- var code = VerifierEligibility();
- Assert.Contains("_userScope.FindActiveUserAsync(tenantId, verifierUserId)", code);
- Assert.Contains("S8ResponsibilityType.Reviewer, verifierUserId", code);
- Assert.Contains("S8ExceptionActionCode.Verify", code);
- Assert.Contains("NotInReviewerPool", code);
- Assert.Contains("NoActionPermission", code);
- Assert.Contains("NotUsable", code);
- }
- /// <summary>
- /// 候选列表与提交校验<b>同一份实现</b>。
- /// <para>两边各写一套,就会出现「页面能选、提交被拒」或更糟的「页面选甲、后端放行乙」。</para>
- /// </summary>
- [Fact]
- public void VerifierCandidates_AndSubmitCheck_ShareOneImplementation()
- {
- var eligibility = VerifierEligibility();
- Assert.Contains("Task<S8VerifierEligibilityResult> CheckAsync", eligibility);
- Assert.Contains("Task<List<S8VerifierCandidateDto>> ListCandidatesAsync", eligibility);
- // 提交复核只调这一个门,不再自己拼权限判据
- var flow = TaskFlow();
- Assert.Contains("_verifierEligibility.CheckAsync(tenantId, verifier.UserId, e)", flow);
- Assert.DoesNotContain(
- "_actionAuthorizer.AuthorizeAsync(tenantId, verifier.UserId, S8ExceptionActionCode.Verify)", flow);
- }
- /// <summary>T15 绕过 UI 直接调 API 注入池外 verifier 同样被拒 —— 门在服务端。</summary>
- [Fact]
- public void T15_ApiInjectedOutsiderVerifier_IsRejectedServerSide()
- {
- var flow = TaskFlow();
- var idx = flow.IndexOf("SubmitVerificationAsync(", StringComparison.Ordinal);
- Assert.True(idx > 0);
- var body = flow[idx..(idx + 1800)];
- // 资格校验必须发生在状态迁移与落库之前
- var checkIdx = body.IndexOf("_verifierEligibility.CheckAsync", StringComparison.Ordinal);
- var mutateIdx = body.IndexOf("e.VerifierUserId = verifier.UserId", StringComparison.Ordinal);
- Assert.True(checkIdx > 0 && mutateIdx > 0);
- Assert.True(checkIdx < mutateIdx, "复核人资格必须在写入 verifier_user_id 之前判定");
- }
- /// <summary>
- /// T16 <b>页面选定的检验人 == 审批任务的实际处理人</b>。
- ///
- /// <para>这是本批修的核心错位:<c>ResolveApprovers</c> 只看流程定义节点,
- /// <b>完全不读 BizData</b>,提交复核时传的 <c>verifierUserId</c> 从未参与审批人解析。
- /// 当前 UAT 看起来正常只是因为节点角色恰好只有一名成员。</para>
- /// </summary>
- [Fact]
- public void T16_SelectedVerifier_BecomesTheActualFlowTaskAssignee()
- {
- var code = ClosureHandler();
- Assert.Contains("AlignTaskToSelectedVerifierAsync", code);
- Assert.Contains("t.Status == FlowTaskStatusEnum.Pending", code);
- Assert.Contains("keep.AssigneeId = verifierId;", code);
- // 对齐必须发生在 OnFlowStarted 里 —— StartFlow 的顺序是先建任务再回调
- var started = code.IndexOf("OnFlowStarted(long bizId", StringComparison.Ordinal);
- var align = code.IndexOf("await AlignTaskToSelectedVerifierAsync", StringComparison.Ordinal);
- Assert.True(started > 0 && align > started);
- // 多余待办必须删除:留着等于池外的人仍能点通过,而 S8 侧校验又会拒绝他
- Assert.Contains("AsDeleteable().Where(t => drop.Contains(t.Id))", code);
- }
- /// <summary>对齐失败不回滚已提交的复核,但必须留下可查的日志 —— 静默失败是本模块的老病。</summary>
- [Fact]
- public void TaskAlignmentFailure_IsLoggedNotSwallowed()
- {
- var code = ClosureHandler();
- Assert.Contains("s8_verify_task_aligned", code);
- Assert.Contains("s8_verify_task_align_failed", code);
- }
- /// <summary>不改共用的 FlowEngine —— 它是 S1/S5/S6/S7 共用的平台组件。</summary>
- [Fact]
- public void FlowEngine_IsNotModifiedByThisBatch()
- {
- var engine = Path.GetFullPath(Path.Combine(
- PluginRoot, "../Admin.NET.Plugin.ApprovalFlow/Service/FlowEngine/FlowEngineService.cs"));
- Assert.True(File.Exists(engine));
- var code = File.ReadAllText(engine);
- // 没有为 S8 加 BizData 动态审批人分支
- Assert.DoesNotContain("verifierUserId", code);
- Assert.DoesNotContain("S8ResponsibilityType", code);
- }
- // ══════════════════════════════ 升级契约 ══════════════════════════════
- /// <summary>
- /// T19 超时升级按<b>该规则的升级账号池</b>解析,不再用 exception_type.escalate_role_code。
- ///
- /// <para>旧字段按异常类型跨租户查,实测会把 B 租户的配置用到 A 租户的异常上
- /// (原实现注释已自陈)。责任池天然带 tenant_id,不存在这条越界路径。</para>
- /// </summary>
- [Fact]
- public void T19_Escalation_ResolvesExactlyTheRuleEscalationPool()
- {
- var code = Escalation();
- Assert.Contains("S8ResponsibilityType.Escalation", code);
- Assert.Contains("_pools.GetValidMemberIdsAsync", code);
- // 旧 authority 已彻底移除(含类型仓储)
- Assert.DoesNotContain("EscalateRoleCode", code);
- Assert.DoesNotContain("AdoS8ExceptionType", code);
- Assert.DoesNotContain("_typeRep", code);
- }
- /// <summary>空升级池 fail closed:跳过并落日志,不回落、不猜。</summary>
- [Fact]
- public void EmptyEscalationPool_SkipsLoudlyInsteadOfGuessing()
- {
- var code = Escalation();
- Assert.Contains("reason=escalation_pool_empty", code);
- Assert.Contains("if (escalationPool.Count == 0)", code);
- // 人工提报没有来源规则,不参与规则级自动升级
- Assert.Contains("reason=no_source_rule", code);
- }
- // ══════════════════════════════ 通知推导 ══════════════════════════════
- /// <summary>
- /// T20/T21/T22 通知对象由责任关系<b>推导</b>,业务用户不选技术枚举。
- /// </summary>
- [Fact]
- public void T20_T21_T22_RecipientsAreDerivedFromResponsibilities()
- {
- Assert.Equal(S8RecipientType.HandlerPool,
- S8NotificationCatalog.ResolveDefaultRecipientType(S8NotifyEventCode.ExceptionCreated));
- Assert.Equal(S8RecipientType.Reviewer,
- S8NotificationCatalog.ResolveDefaultRecipientType(S8NotifyEventCode.VerificationSubmitted));
- Assert.Equal(S8RecipientType.EscalationPool,
- S8NotificationCatalog.ResolveDefaultRecipientType(S8NotifyEventCode.EscalationTriggered));
- Assert.Equal(S8RecipientType.Assignee,
- S8NotificationCatalog.ResolveDefaultRecipientType(S8NotifyEventCode.ExceptionClaimed));
- // 转派后 assignee_user_id 已经是新处理人,不需要额外的收件人类型
- Assert.Equal(S8RecipientType.Assignee,
- S8NotificationCatalog.ResolveDefaultRecipientType(S8NotifyEventCode.ExceptionTransferred));
- }
- /// <summary>
- /// <b>不制造不存在的事件</b>:目录里每个事件都必须有真实派发点,
- /// 且每个事件都必须有默认收件人推导。
- /// </summary>
- [Fact]
- public void EveryCatalogEvent_HasBothADispatchSiteAndADerivedRecipient()
- {
- var serviceDir = Path.Combine(PluginRoot, "Service", "S8");
- var allCode = string.Join('\n', Directory.GetFiles(serviceDir, "*.cs", SearchOption.AllDirectories)
- .Select(File.ReadAllText));
- foreach (var e in S8NotificationCatalog.Events)
- {
- var member = e.Code.Split('_')
- .Select(p => char.ToUpperInvariant(p[0]) + p[1..].ToLowerInvariant())
- .Aggregate(string.Empty, (a, b) => a + b);
- Assert.True(allCode.Contains($"S8NotifyEventCode.{member}", StringComparison.Ordinal),
- $"事件 {e.Code} 在 Service/S8 下没有任何派发点 —— 配了却永远不触发的通知比没有通知更难排查");
- Assert.NotNull(S8NotificationCatalog.ResolveDefaultRecipientType(e.Code));
- }
- }
- /// <summary>「复核通过 / 复核退回」刻意缺席 —— 代码里没有这两个派发点。</summary>
- [Fact]
- public void NonExistentVerificationEvents_AreNotInvented()
- {
- Assert.DoesNotContain("VERIFICATION_APPROVED", string.Join(',', S8NotificationCatalog.Events.Select(e => e.Code)));
- Assert.DoesNotContain("VERIFICATION_REJECTED", string.Join(',', S8NotificationCatalog.Events.Select(e => e.Code)));
- }
- /// <summary>
- /// 通知里的 REVIEWER 不再解析审批流节点。
- /// <para>审批人来自流程定义的角色,与「谁有资格复核这条规则」不是同一个问题,
- /// 且会让通知链随流程节点结构悄悄变化。</para>
- /// </summary>
- [Fact]
- public void NotificationReviewer_NoLongerParsesApprovalFlowNodes()
- {
- var code = NotifyResolver();
- Assert.DoesNotContain("ResolveFlowApproversAsync", code);
- Assert.DoesNotContain("_reviewSource", code);
- Assert.Contains("S8ResponsibilityType.Reviewer", code);
- Assert.Contains("S8ResponsibilityType.Escalation", code);
- }
- /// <summary>通知开关写入口只接布尔,收件人类型由目录推导 —— 页面不选技术枚举。</summary>
- [Fact]
- public void NotificationToggle_TakesOnlyABooleanFromTheUi()
- {
- var svc = CodeOnly("Service/S8/S8NotificationRecipientConfigService.cs");
- Assert.Contains("SetEventEnabledAsync(S8TrustedScope scope, string ruleCode, string eventCode, bool enabled)", svc);
- Assert.Contains("S8NotificationCatalog.ResolveDefaultRecipientType(eventCode)", svc);
- var dto = Read("Controllers/S8/AdoS8ConfigWatchRulesController.cs");
- Assert.Contains("class AdoS8NotifyEventToggleDto", dto);
- }
- // ══════════════════════════════ 回归 ══════════════════════════════
- /// <summary>T25 First Claim Wins 未被本批改动:仍是一条带前置条件的原子 UPDATE。</summary>
- [Fact]
- public void T25_FirstClaimWins_IsUnchanged()
- {
- var code = TaskFlow();
- Assert.Contains("x.Status == fromStatus", code);
- Assert.Contains("x.AssigneeUserId == null", code);
- Assert.Contains("该异常已被其他人员认领", code);
- // 认领资格仍是 动作权限 ∩ 处理池
- Assert.Contains("_claimEligibility.CheckAsync(tenantId, currentUserId, e)", code);
- }
- /// <summary>迁移不绑定任何具体账号或角色 —— 部署后三个池初始为空,由管理员显式配置。</summary>
- [Fact]
- public void Migration_BindsNoConcreteAccountsOrRoles()
- {
- var sql = File.ReadAllText(Path.GetFullPath(Path.Combine(
- PluginRoot, "../../Admin.NET.Web.Entry/UpdateScripts/1.0.503.sql")));
- Assert.DoesNotContain("INSERT INTO `ado_s8_rule_responsibility_user`", sql);
- Assert.DoesNotContain("ROLE_", sql);
- Assert.DoesNotContain("DELIMITER", sql); // AutoVersionUpdate 会整份拒绝含该词的脚本
- // 唯一索引断言按**列成员**判定,不是只看索引名是否存在(1.0.496 踩过这个坑)
- Assert.Contains("COLUMN_NAME IN ('tenant_id','rule_code','responsibility_type','user_id')", sql);
- Assert.Contains("s8_503_BLOCKED_cross_tenant_member", sql);
- Assert.Contains("s8_503_BLOCKED_unknown_responsibility_type", sql);
- }
- }
|