|
|
@@ -142,14 +142,21 @@ public class S7FqcGenericApprovalGuardContractTests
|
|
|
Assert.DoesNotContain(bizType, Dict());
|
|
|
}
|
|
|
|
|
|
- /// <summary>§9 禁止散落 if:不得在四个动作里各写一遍 S7 的判断。</summary>
|
|
|
+ /// <summary>
|
|
|
+ /// §9 禁止散落 if:不得在动作方法里各写一遍 S7 的判断。
|
|
|
+ ///
|
|
|
+ /// <para>字面量允许出现的位置恰好两处,且都是声明式集合成员,不是条件分支:
|
|
|
+ /// ① <c>DedicatedEntryOnlyBizTypes</c> 的键(名单与文案的唯一来源);
|
|
|
+ /// ② <c>FlowMutationDedicatedOnlyBizTypes</c> 的成员(封堵范围是否延伸到变更型入口)。
|
|
|
+ /// 真正要防的「散落 if」由下面的 <c>DoesNotContain("== ...")</c> 断言守住。</para>
|
|
|
+ /// </summary>
|
|
|
[Fact]
|
|
|
public void NoScatteredPerBizTypeIfStatements()
|
|
|
{
|
|
|
var s = FlowTaskService();
|
|
|
- // BizType 字面量只应出现在名单里(1 次),不应出现在任何 if 条件中
|
|
|
- Assert.Equal(1, CountOf(s, $"\"{BizType}\""));
|
|
|
+ Assert.Equal(2, CountOf(s, $"\"{BizType}\""));
|
|
|
Assert.DoesNotContain($"== \"{BizType}\"", s);
|
|
|
+ Assert.DoesNotContain($"if (instance.BizType == \"{BizType}\"", s);
|
|
|
}
|
|
|
|
|
|
// ── §12 权威来源与不泄漏 ──
|
|
|
@@ -235,6 +242,167 @@ public class S7FqcGenericApprovalGuardContractTests
|
|
|
Assert.Contains("SyncBillToStdSafeAsync", body);
|
|
|
}
|
|
|
|
|
|
+ // ══════════════════════════════════════════════════════════════
|
|
|
+ // 变更型通用入口封堵(S7-FQC-GENERIC-FLOW-MUTATION-GUARD-1)
|
|
|
+ //
|
|
|
+ // 上一批 Evidence Audit 确认:DedicatedEntryOnly 守卫此前只挂在
|
|
|
+ // approve / reject / batchApprove / batchReject 四处,而 ReturnToPrev /
|
|
|
+ // Withdraw / Transfer / AddSign 四个**会改流程状态**的入口全部直通引擎、零守卫。
|
|
|
+ // 本区块冻结这四处的封堵,并冻结「不得顺手扩大到其它 BizType」。
|
|
|
+ // ══════════════════════════════════════════════════════════════
|
|
|
+
|
|
|
+ private const string MutationGuardByTask = "EnsureDedicatedFlowMutationOnlyByTaskAsync";
|
|
|
+ private const string MutationGuardByInstance = "EnsureDedicatedFlowMutationOnlyByInstanceAsync";
|
|
|
+
|
|
|
+ private static string MutationScope() =>
|
|
|
+ Slice(FlowTaskService(), "FlowMutationDedicatedOnlyBizTypes = new(StringComparer.Ordinal)", "};");
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// T1–T4:四个变更型入口各自的守卫必须是方法的**第一条语句**。
|
|
|
+ ///
|
|
|
+ /// <para>为什么必须是第一条:引擎侧这四个动作都没有 <c>[UnitOfWork]</c>,
|
|
|
+ /// 一旦进入就按「取消同节点待办 → 改 task 状态 → 改 CurrentNodeId → 建新任务」
|
|
|
+ /// 逐步提交,中途抛错无法回滚。守卫晚一步就不再是 ZERO MUTATION。</para>
|
|
|
+ /// </summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData("public async Task ReturnToPrev(TaskActionInput input)", MutationGuardByTask, "input.TaskId")]
|
|
|
+ [InlineData("public async Task Transfer(TaskTransferInput input)", MutationGuardByTask, "input.TaskId")]
|
|
|
+ [InlineData("public async Task AddSign(TaskAddSignInput input)", MutationGuardByTask, "input.TaskId")]
|
|
|
+ [InlineData("public async Task Withdraw(TaskWithdrawInput input)", MutationGuardByInstance, "input.InstanceId")]
|
|
|
+ public void FlowMutationEntries_GuardIsFirstStatement(string signature, string guard, string arg)
|
|
|
+ {
|
|
|
+ var body = Slice(FlowTaskService(), signature, "}");
|
|
|
+ var first = body.Split('\n').Select(l => l.Trim())
|
|
|
+ .Where(l => l != "" && l != "{" && !l.StartsWith("//", StringComparison.Ordinal)
|
|
|
+ && !l.StartsWith("public ", StringComparison.Ordinal))
|
|
|
+ .FirstOrDefault();
|
|
|
+ Assert.Equal($"await {guard}({arg});", first);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>T5:守卫调用必须严格早于对应的 FlowEngine mutation 调用。</summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData("public async Task ReturnToPrev(TaskActionInput input)", "_engine.ReturnToPrev(")]
|
|
|
+ [InlineData("public async Task Transfer(TaskTransferInput input)", "_engine.Transfer(")]
|
|
|
+ [InlineData("public async Task AddSign(TaskAddSignInput input)", "_engine.AddSign(")]
|
|
|
+ [InlineData("public async Task Withdraw(TaskWithdrawInput input)", "_engine.Withdraw(")]
|
|
|
+ public void FlowMutationEntries_GuardPrecedesEngineCall(string signature, string engineCall)
|
|
|
+ {
|
|
|
+ var body = Slice(FlowTaskService(), signature, "}");
|
|
|
+ var guardAt = body.IndexOf("EnsureDedicatedFlowMutationOnly", StringComparison.Ordinal);
|
|
|
+ var engineAt = body.IndexOf(engineCall, StringComparison.Ordinal);
|
|
|
+ Assert.True(guardAt >= 0, $"{signature} 缺少变更型守卫");
|
|
|
+ Assert.True(engineAt > 0, $"未找到引擎调用 {engineCall}");
|
|
|
+ Assert.True(guardAt < engineAt, $"{signature} 的守卫晚于 {engineCall},无法保证 ZERO MUTATION");
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// T6:封堵范围不得顺手扩大——其余三个 dedicated BizType 目前仅封堵同意/拒绝,
|
|
|
+ /// 纳入变更型入口会改变它们的现有行为,属另批范围。
|
|
|
+ /// </summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData("S5_IQC_INSPBILL")]
|
|
|
+ [InlineData("S6_PROCESS_INSPECTION")]
|
|
|
+ [InlineData("IPQC_INSPECTION")]
|
|
|
+ public void FlowMutationScope_ExcludesOtherDedicatedBizTypes(string bizType)
|
|
|
+ {
|
|
|
+ Assert.DoesNotContain(bizType, MutationScope());
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>T6 续:作用域恰好一条,且必须是主名单的子集(证明它不是第二套名单)。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void FlowMutationScope_IsSingleMemberSubsetOfMainDict()
|
|
|
+ {
|
|
|
+ var scope = MutationScope();
|
|
|
+ Assert.Equal(2, CountOf(scope, "\"")); // 恰好一个字符串成员 → 一对引号
|
|
|
+ Assert.Contains($"\"{BizType}\"", scope);
|
|
|
+ Assert.Contains($"[\"{BizType}\"]", Dict());
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>T6 续:通用审批 BizType 不得被纳入变更型封堵。</summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData("CONTRACT_REVIEW")]
|
|
|
+ [InlineData("ORDER_REVIEW")]
|
|
|
+ [InlineData("SMART_OPS_IMPROVEMENT")]
|
|
|
+ public void FlowMutationScope_DoesNotBlockGenericBizTypes(string bizType)
|
|
|
+ {
|
|
|
+ Assert.DoesNotContain(bizType, MutationScope());
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>T7:原 approve/reject/batch 守卫必须仍然调用**原**守卫,未被新守卫替换。</summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData("public async Task Approve(TaskActionInput input)")]
|
|
|
+ [InlineData("public async Task Reject(TaskActionInput input)")]
|
|
|
+ public void ApproveReject_StillUseOriginalGuard(string signature)
|
|
|
+ {
|
|
|
+ var body = Slice(FlowTaskService(), signature, "}");
|
|
|
+ Assert.Contains($"{Guard}(input.TaskId)", body);
|
|
|
+ Assert.DoesNotContain("EnsureDedicatedFlowMutationOnly", body);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// T8:Escalate / Urge 本批明确 OUT OF SCOPE,不得被误加守卫。
|
|
|
+ /// Escalate 当前因 S7 定义未配 manual escalation 而在变更前即抛错;
|
|
|
+ /// Urge 不改流程状态,其权限问题属 ApprovalFlow 通用治理。
|
|
|
+ /// </summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData("public async Task Escalate(TaskActionInput input)", "_engine.Escalate(")]
|
|
|
+ [InlineData("public async Task Urge(TaskUrgeInput input)", "_engine.Urge(")]
|
|
|
+ public void EscalateAndUrge_RemainUntouched(string signature, string engineCall)
|
|
|
+ {
|
|
|
+ var body = Slice(FlowTaskService(), signature, "}");
|
|
|
+ Assert.Contains(engineCall, body);
|
|
|
+ Assert.DoesNotContain("EnsureDedicatedFlowMutationOnly", body);
|
|
|
+ Assert.DoesNotContain(Guard + "(", body);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>两个新守卫自身只读——判定过程不得改任何实体(ZERO MUTATION 的静态前提)。</summary>
|
|
|
+ [Theory]
|
|
|
+ [InlineData(MutationGuardByTask)]
|
|
|
+ [InlineData(MutationGuardByInstance)]
|
|
|
+ public void FlowMutationGuards_PerformNoMutation(string guard)
|
|
|
+ {
|
|
|
+ var body = Slice(FlowTaskService(), $"private async Task {guard}(long", "}\n\n /// <summary>");
|
|
|
+ foreach (var write in new[] { "AsUpdateable", "InsertAsync", "DeleteAsync", "ExecuteCommandAsync", "UseTranAsync" })
|
|
|
+ Assert.DoesNotContain(write, body);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>新守卫的权威 BizType 同样必须来自库,且复用同一字典(不得自带文案体系)。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void FlowMutationGuard_ReadsBizTypeFromDatabaseAndReusesDict()
|
|
|
+ {
|
|
|
+ var byInstance = Slice(FlowTaskService(), $"private async Task {MutationGuardByInstance}(long", "}\n\n /// <summary>");
|
|
|
+ Assert.Contains("_instanceRep.GetByIdAsync(instanceId)", byInstance);
|
|
|
+ Assert.Contains("DedicatedEntryOnlyBizTypes.TryGetValue(instance.BizType", byInstance);
|
|
|
+ Assert.DoesNotContain("input.BizType", byInstance);
|
|
|
+
|
|
|
+ var byTask = Slice(FlowTaskService(), $"private async Task {MutationGuardByTask}(long", "}\n\n /// <summary>");
|
|
|
+ Assert.Contains("_taskRep.GetByIdAsync(taskId)", byTask);
|
|
|
+ Assert.Contains($"{MutationGuardByInstance}(task.InstanceId)", byTask);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>S7 文案必须覆盖本批新增的四个动作,否则用户点「转办」却看到「不支持同意/拒绝」。</summary>
|
|
|
+ [Fact]
|
|
|
+ public void GuardMessage_CoversFlowMutationActions()
|
|
|
+ {
|
|
|
+ var line = Dict().Split('\n').Single(l => l.Contains($"[\"{BizType}\"]", StringComparison.Ordinal));
|
|
|
+ foreach (var act in new[] { "退回", "撤回", "转办", "加签" })
|
|
|
+ Assert.Contains(act, line);
|
|
|
+ }
|
|
|
+
|
|
|
+ /// <summary>
|
|
|
+ /// 封堵成立的前提:S7 合法退回能力仍在专用入口上。
|
|
|
+ /// 通用 ReturnToPrev 被封后,N2→N1 必须仍可经 supervisor-reject 完成。
|
|
|
+ /// </summary>
|
|
|
+ [Fact]
|
|
|
+ public void FlowMutationBlock_LeavesDedicatedRejectAsTheOnlyReturnPath()
|
|
|
+ {
|
|
|
+ var s = FlowService();
|
|
|
+ Assert.Contains("[HttpPost(\"supervisor-reject\")]", s);
|
|
|
+ Assert.Contains("_flowEngine.ReturnToPrev(", s);
|
|
|
+ // 专用入口直调引擎,不经 FlowTaskService,故不会被本批守卫拦住
|
|
|
+ Assert.DoesNotContain("FlowTaskService", s);
|
|
|
+ }
|
|
|
+
|
|
|
private static int CountOf(string src, string needle)
|
|
|
{
|
|
|
int n = 0, i = 0;
|