using System.Text.Json;
using System.Text.Json.Nodes;
using Admin.NET.Plugin.AiDOP.Service.S6;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S6.Manufacturing;
///
/// S6-LEGACY-SNAPSHOT-MIGRATION-1 契约测试:存量运行中实例的审批权威快照迁移。
///
/// 两层覆盖:
///
/// - 行为级——ParseNumericRoleRefs / RewriteAuthority 是纯静态方法,
/// 不依赖 DB,可直接喂真实形状的 FlowJson 断言。快照解析与改写是本批最容易悄悄改坏的地方
/// (approverType 语义、token 顺序、只动 approverIds),必须真跑。
/// - 源码契约级——安全谓词(ACTIVE 判据、租户反查、跨租户限定、目标角色唯一性与成员门禁、
/// 回滚来源门禁、并发守卫、留证同事务、append-only、批次边界)依赖
/// ISqlSugarClient,裸 xUnit 进程无法实例化(见 S8ExceptionFlowTenantContextContractTests),
/// 故按本仓既有惯例做源码断言;真实数据行为由本批 Runtime R1–R10 覆盖。
///
///
public class S6LegacyFlowAuthorityMigrationContractTests
{
private static string Svc() => File.ReadAllText(FindFile(
"server", "Plugins", "Admin.NET.Plugin.AiDOP", "Service", "S6", "S6LegacyFlowAuthorityMigrationService.cs"));
private static string Entity() => File.ReadAllText(FindFile(
"server", "Plugins", "Admin.NET.Plugin.AiDOP", "Entity", "S6", "AdoS6FlowAuthorityMigrationLog.cs"));
private static string Engine() => File.ReadAllText(FindFile(
"server", "Plugins", "Admin.NET.Plugin.ApprovalFlow", "Service", "FlowEngine", "FlowEngineService.cs"));
/// 与真实快照同形:N1=Initiator、N2/N3=Role 数字、另含 SpecificUser / Department 干扰节点。
private static string SampleSnapshot(string n2Ids = "1329916010002", string n3Ids = "1329916010003") => JsonSerializer.Serialize(new
{
nodes = new object[]
{
new { id = "start", type = "bpmn:startEvent", properties = new { nodeName = "开始" } },
new { id = "N1_SUBMIT", type = "bpmn:userTask", properties = new { nodeName = "提交审核", approverType = "Initiator", approverIds = "", approverNames = "发起人" } },
new { id = "N2_SUP_REVIEW", type = "bpmn:userTask", properties = new { nodeName = "检验主管审核", approverType = "Role", approverIds = n2Ids, approverNames = "过程检验主管" } },
new { id = "GW_RESULT", type = "bpmn:exclusiveGateway", properties = new { nodeName = "质量结果分流" } },
new { id = "N3_QE_DISPOSITION", type = "bpmn:userTask", properties = new { nodeName = "质量处置", approverType = "Role", approverIds = n3Ids, approverNames = "过程检验质量工程师" } },
// 干扰项:数字但语义不是 RoleId
new { id = "X_SPECIFIC", type = "bpmn:userTask", properties = new { nodeName = "指定人", approverType = "SpecificUser", approverIds = "1300000000101", approverNames = "超管" } },
new { id = "X_DEPT", type = "bpmn:userTask", properties = new { nodeName = "部门", approverType = "Department", approverIds = "1300000000002", approverNames = "市场部" } },
new { id = "end", type = "bpmn:endEvent", properties = new { nodeName = "结束" } },
},
edges = new object[] { new { id = "e1", sourceNodeId = "start", targetNodeId = "N1_SUBMIT" } },
});
// ───────────── 行为级:解析 ─────────────
/// 只认 approverType=Role 的数字 token;SpecificUser / Department 的数字不得被当成 RoleId。
[Fact]
public void Parse_OnlyPicksRoleNodes_NotSpecificUserOrDepartment()
{
var refs = S6LegacyFlowAuthorityMigrationService.ParseNumericRoleRefs(SampleSnapshot());
Assert.Equal(2, refs.Count);
Assert.Equal(new[] { "N2_SUP_REVIEW", "N3_QE_DISPOSITION" }, refs.Select(r => r.NodeId).ToArray());
Assert.DoesNotContain("1300000000101", refs.SelectMany(r => r.NumericTokens)); // SpecificUser 的 UserId
Assert.DoesNotContain("1300000000002", refs.SelectMany(r => r.NumericTokens)); // Department 的 OrgId
}
/// Initiator 节点 approverIds 为空串,不算 authority。
[Fact]
public void Parse_IgnoresInitiatorNode()
{
var refs = S6LegacyFlowAuthorityMigrationService.ParseNumericRoleRefs(SampleSnapshot());
Assert.DoesNotContain("N1_SUBMIT", refs.Select(r => r.NodeId));
}
/// 已迁过的快照(全 RoleCode)解析不出数字 token —— 这就是幂等判据。
[Fact]
public void Parse_AlreadyMigratedSnapshot_YieldsNothing()
{
var refs = S6LegacyFlowAuthorityMigrationService.ParseNumericRoleRefs(
SampleSnapshot("ROLE_S6_IPQC_SUPERVISOR", "ROLE_S6_IPQC_QUALITY_ENGINEER"));
Assert.Empty(refs);
}
/// 多 token 逐个识别(即使当前实例是单 token)。
[Fact]
public void Parse_HandlesMultipleTokens()
{
var refs = S6LegacyFlowAuthorityMigrationService.ParseNumericRoleRefs(SampleSnapshot("111,222,333"));
var n2 = refs.Single(r => r.NodeId == "N2_SUP_REVIEW");
Assert.Equal(new[] { "111", "222", "333" }, n2.NumericTokens.ToArray());
}
/// 畸形 JSON 不得抛异常,返回空(fail-closed:选不中就不改)。
[Theory]
[InlineData("")]
[InlineData("not json")]
[InlineData("{\"nodes\":\"wrong-type\"}")]
public void Parse_MalformedInput_ReturnsEmpty(string bad)
{
Assert.Empty(S6LegacyFlowAuthorityMigrationService.ParseNumericRoleRefs(bad));
}
// ───────────── 行为级:改写 ─────────────
/// 只改 Role 节点的 approverIds;节点集合、名称、edges、approverNames 一概不动。
[Fact]
public void Rewrite_ChangesOnlyRoleApproverIds()
{
var before = SampleSnapshot();
var after = S6LegacyFlowAuthorityMigrationService.RewriteAuthority(before, new Dictionary
{
["1329916010002"] = "ROLE_S6_IPQC_SUPERVISOR",
["1329916010003"] = "ROLE_S6_IPQC_QUALITY_ENGINEER",
});
Assert.NotNull(after);
var diffs = StructuralDiff(before, after!);
Assert.Equal(2, diffs.Count);
Assert.Contains(".nodes[2].properties.approverIds", diffs.Keys);
Assert.Contains(".nodes[4].properties.approverIds", diffs.Keys);
Assert.Equal("ROLE_S6_IPQC_SUPERVISOR", diffs[".nodes[2].properties.approverIds"].after);
Assert.Equal("ROLE_S6_IPQC_QUALITY_ENGINEER", diffs[".nodes[4].properties.approverIds"].after);
}
/// SpecificUser / Department 的数字即使出现在 tokenMap 里也不得被改。
[Fact]
public void Rewrite_NeverTouchesNonRoleNodes()
{
var before = SampleSnapshot();
var after = S6LegacyFlowAuthorityMigrationService.RewriteAuthority(before, new Dictionary
{
["1329916010002"] = "ROLE_S6_IPQC_SUPERVISOR",
["1300000000101"] = "SHOULD_NOT_BE_APPLIED", // SpecificUser 的 UserId
["1300000000002"] = "SHOULD_NOT_BE_APPLIED", // Department 的 OrgId
});
Assert.NotNull(after);
Assert.DoesNotContain("SHOULD_NOT_BE_APPLIED", after!);
Assert.Contains("\"approverIds\":\"1300000000101\"", after);
Assert.Contains("\"approverIds\":\"1300000000002\"", after);
}
/// 多 token 保持原顺序与原数量;未映射的 token 原样留下。
[Fact]
public void Rewrite_PreservesTokenOrderAndCount()
{
var after = S6LegacyFlowAuthorityMigrationService.RewriteAuthority(
SampleSnapshot("111,222,333"), new Dictionary { ["222"] = "ROLE_B" });
Assert.NotNull(after);
Assert.Contains("\"approverIds\":\"111,ROLE_B,333\"", after!);
}
/// 无可改之处时返回 null,调用方据此不发 UPDATE。
[Fact]
public void Rewrite_NoApplicableToken_ReturnsNull()
{
Assert.Null(S6LegacyFlowAuthorityMigrationService.RewriteAuthority(
SampleSnapshot(), new Dictionary { ["999999"] = "ROLE_X" }));
}
/// 畸形输入返回 null,不抛。
[Fact]
public void Rewrite_MalformedInput_ReturnsNull()
{
Assert.Null(S6LegacyFlowAuthorityMigrationService.RewriteAuthority("not json", new Dictionary()));
}
// ───────────── 源码契约:安全谓词 ─────────────
/// BizType 范围必须与引擎的严格守卫白名单逐字一致。
[Fact]
public void Scope_MatchesEngineStrictWhitelist()
{
Assert.Equal(new[] { "IPQC_INSPECTION", "S6_PROCESS_INSPECTION" },
S6LegacyFlowAuthorityMigrationService.TargetBizTypes.ToArray());
var set = Slice(Engine(), "TenantStrictRoleBizTypes = new(StringComparer.Ordinal)", "};");
Assert.Contains("\"IPQC_INSPECTION\"", set);
Assert.Contains("\"S6_PROCESS_INSPECTION\"", set);
}
/// ACTIVE 判据不得只看 Status —— 终态实例的 CurrentNodeId 可能仍停在 N3_*。
[Fact]
public void Selection_RequiresRunningAndPendingTask()
{
var body = Slice(Svc(), "public async Task> SelectCandidatesAsync", "public async Task MigrateAsync");
Assert.Contains("x.Status == FlowInstanceStatusEnum.Running", body);
Assert.Contains("t.Status == FlowTaskStatusEnum.Pending", body);
Assert.Contains("if (!hasPending) continue;", body);
}
/// §57 终态实例必须被 Running 谓词排除,本服务不得出现「顺带把历史也迁了」的路径。
[Fact]
public void Selection_NeverTouchesTerminalInstances()
{
var body = Slice(Svc(), "public async Task> SelectCandidatesAsync", "public async Task MigrateAsync");
Assert.DoesNotContain("FlowInstanceStatusEnum.Approved", body);
Assert.DoesNotContain("FlowInstanceStatusEnum.Rejected", body);
Assert.DoesNotContain("FlowInstanceStatusEnum.Cancelled", body);
}
/// 批次边界:这是「一次性迁移」与「长期 auto-heal」的分界,未来新坏实例必须响亮失败。
[Fact]
public void Selection_IsBatchScopedByCutoff()
{
var s = Svc();
Assert.Contains("BatchCutoff", s);
Assert.Contains("x.StartTime < cutoff", s);
}
/// §15 Effective Tenant 必须由业务实体反查,禁止取登录用户租户。
[Fact]
public void Tenant_ResolvedFromBusinessEntity_NotLoginUser()
{
var s = Svc();
var resolver = Slice(s, "private async Task ResolveEffectiveTenantAsync", "internal static List ParseNumericRoleRefs");
Assert.Contains("FROM ado_s6_process_inspection_bill WHERE id=@id", resolver);
Assert.Contains("FROM qms_gcjyd WHERE id=@id", resolver);
Assert.DoesNotContain("_userManager", s);
Assert.DoesNotContain("HttpContext", s);
}
/// §58 同租户 numeric RoleId 是合法引用,必须被挡下不改 —— numeric ≠ bad。
[Fact]
public void Mapping_SameTenantNumericRoleId_IsNotMigrated()
{
var s = Svc();
Assert.Contains("legacyRole.TenantId == c.EffectiveTenantId", s);
Assert.Contains("非跨租户引用,不在迁移范围", s);
}
/// §19 目标角色必须恰好一个启用项,不得 First() 随便挑。
[Fact]
public void Mapping_RequiresExactlyOneEnabledTargetRole()
{
var s = Svc();
Assert.Contains("targets.Count != 1", s);
Assert.Contains("AMBIGUOUS TARGET ROLE", s);
Assert.Contains("x.Status == StatusEnum.Enable", s);
}
/// §18 目标角色必须至少有一个同租户成员,否则迁完仍解析 0 人。
[Fact]
public void Mapping_RequiresSameTenantMember()
{
var s = Svc();
Assert.Contains("u.TenantId == c.EffectiveTenantId", s);
Assert.Contains("if (sameTenantMembers == 0)", s);
Assert.Contains("BLOCKED_TARGET_AUTHORITY", s);
}
/// §59 任一 token 映射不出来 → 整条实例不迁,禁止部分迁移。
[Fact]
public void Mapping_PartialFailure_AbortsWholeInstance()
{
var body = Slice(Svc(), "private async Task MigrateOneAsync", "/// 挡下的候选同样留证");
// 每个 Block 分支都必须 return,不能继续往下改写
Assert.Contains("result, _ => result.BlockedByTargetAuthority++);\n return;", body);
Assert.Contains("禁止部分迁移", Svc());
}
/// §20/§21 回滚来源必须恰好 1 行且与当前快照 MD5 一致。
[Fact]
public void RollbackSource_GateRequiresExactlyOneMatchingVersionRow()
{
var s = Svc();
Assert.Contains("v.FlowId == inst.FlowId && v.Version == inst.FlowVersion", s);
Assert.Contains("versionRows.Count != 1 || usable.Count != 1", s);
Assert.Contains("BLOCKED_ROLLBACK_SOURCE", s);
}
/// §32 实例表无 version/checksum,UPDATE 必须带改前 MD5 乐观锁且断言 affected==1。
[Fact]
public void Update_UsesBeforeMd5ConcurrencyGuard()
{
var s = Svc();
Assert.Contains("MD5(FlowJsonSnapshot)=@beforeMd5", s);
Assert.Contains("if (affected != 1)", s);
Assert.Contains("CONCURRENT MODIFICATION", s);
}
/// §13 留证 INSERT 与快照 UPDATE 必须同一事务。
[Fact]
public void Evidence_AndUpdate_ShareOneTransaction()
{
var body = Slice(Svc(), "private async Task MigrateOneAsync", "/// 挡下的候选同样留证");
var tranAt = body.IndexOf("_db.AsTenant().UseTranAsync(", StringComparison.Ordinal);
var logAt = body.IndexOf("_logRep.AsInsertable(new AdoS6FlowAuthorityMigrationLog", StringComparison.Ordinal);
var updAt = body.IndexOf("UPDATE ApprovalFlowInstance", StringComparison.Ordinal);
Assert.True(tranAt > 0, "缺少事务边界");
Assert.True(logAt > tranAt, "留证 INSERT 必须在事务内");
Assert.True(updAt > logAt, "先留证再改写");
Assert.Contains("if (!tran.IsSuccess)", body);
}
/// §12 留证必须 append-only:不得出现任何删除/清空。
[Fact]
public void Evidence_IsAppendOnly()
{
var s = Svc();
Assert.DoesNotContain("AsDeleteable", s);
Assert.DoesNotContain("_logRep.AsUpdateable", s);
Assert.Contains("append-only", Entity());
}
/// §11 留证字段齐全 —— 「为什么改/改前是什么/怎么回滚」必须都能回答。
[Theory]
[InlineData("migration_batch")]
[InlineData("instance_id")]
[InlineData("biz_type")]
[InlineData("biz_id")]
[InlineData("effective_tenant_id")]
[InlineData("before_snapshot")]
[InlineData("after_snapshot")]
[InlineData("before_md5")]
[InlineData("after_md5")]
[InlineData("authority_mapping")]
[InlineData("rollback_source")]
[InlineData("reason")]
[InlineData("outcome")]
[InlineData("created_at")]
public void Evidence_HasRequiredColumn(string column)
{
Assert.Contains($"ColumnName = \"{column}\"", Entity());
}
/// 不得复用 S8 那张每轮全量重写的表 —— 迁移留证一旦被抹掉,回滚依据就消失了。
[Fact]
public void Evidence_DoesNotReuseS8RepairLog()
{
Assert.DoesNotContain("AdoS8ApprovalFlowRepairLog", Svc());
Assert.Contains("ado_s6_flow_authority_migration_log", Entity());
}
/// §27 目标表达必须是 RoleCode,不得写目标租户物理 RoleId。
[Fact]
public void Target_UsesRoleCode_NotTargetLocalRoleId()
{
var s = Svc();
Assert.Contains("tokenMap[token] = code;", s);
Assert.DoesNotContain("tokenMap[token] = target.Id", s);
}
/// §3/§4 本服务不得触碰引擎、守卫白名单或 resolver —— 它是数据迁移,不是兼容层。
[Theory]
[InlineData("TenantStrictRoleBizTypes")]
[InlineData("DedicatedEntryOnlyBizTypes")]
[InlineData("ResolveApprovers")]
[InlineData("EnsureRoleAuthorityTenantScoped")]
public void Migration_DoesNotTouchRuntimeGuards(string symbol)
{
Assert.DoesNotContain(symbol + "(", Svc());
}
/// §29/§30 不得改历史事实表,也不得切 FlowId/FlowVersion。
[Theory]
[InlineData("UPDATE ApprovalFlowTask")]
[InlineData("UPDATE ApprovalFlowLog")]
[InlineData("UPDATE ApprovalFlowCompletedNode")]
[InlineData("SET FlowId")]
[InlineData("SET FlowVersion")]
public void Migration_NeverWritesHistoryOrFlowIdentity(string forbidden)
{
Assert.DoesNotContain(forbidden, Svc());
}
/// §31 不得取消旧实例或另起新实例 —— 断言实际调用形态,不误伤文档里提到的 StartFlowCore。
[Theory]
[InlineData("_flowEngine")]
[InlineData(".StartFlow(")]
[InlineData(".Withdraw(")]
[InlineData("Insertable(new ApprovalFlowInstance")]
public void Migration_NeverRestartsInstance(string forbidden)
{
Assert.DoesNotContain(forbidden, Svc());
}
private static Dictionary StructuralDiff(string a, string b)
{
var fa = Flatten(JsonNode.Parse(a));
var fb = Flatten(JsonNode.Parse(b));
return fa.Keys.Union(fb.Keys)
.Where(k => !Equals(fa.GetValueOrDefault(k), fb.GetValueOrDefault(k)))
.ToDictionary(k => k, k => (fa.GetValueOrDefault(k), fb.GetValueOrDefault(k)));
}
private static Dictionary Flatten(JsonNode? node, string path = "")
{
var flat = new Dictionary();
switch (node)
{
case JsonObject obj:
foreach (var kv in obj)
foreach (var inner in Flatten(kv.Value, $"{path}.{kv.Key}")) flat[inner.Key] = inner.Value;
break;
case JsonArray arr:
for (var i = 0; i < arr.Count; i++)
foreach (var inner in Flatten(arr[i], $"{path}[{i}]")) flat[inner.Key] = inner.Value;
break;
// 标量统一取原值字符串(不带 JSON 引号),否则断言里要写成 "\"x\"" 影响可读性
case JsonValue val:
flat[path] = val.TryGetValue(out var s) ? s : val.ToJsonString();
break;
default:
flat[path] = node?.ToJsonString();
break;
}
return flat;
}
private static string Slice(string src, string from, string to)
{
var a = src.IndexOf(from, StringComparison.Ordinal);
Assert.True(a >= 0, $"未找到起点:{from}");
var b = src.IndexOf(to, a + from.Length, StringComparison.Ordinal);
return b > a ? src[a..b] : src[a..];
}
private static string FindFile(params string[] parts)
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null)
{
var candidate = Path.Combine(new[] { dir.FullName }.Concat(parts).ToArray());
if (File.Exists(candidate)) return candidate;
dir = dir.Parent;
}
throw new FileNotFoundException(string.Join("/", parts));
}
}