using System.Reflection; using Admin.NET.Plugin.AiDOP.Dto.S8; using Admin.NET.Plugin.AiDOP.Entity.S8; using Admin.NET.Plugin.AiDOP.Infrastructure.S8; using Admin.NET.Plugin.AiDOP.Service.S8; using Xunit; namespace Admin.NET.Plugin.AiDOP.Tests.S8; /// /// S8-MANUAL-DIRECT-ASSIGN-1:主动提报「点对点派单」契约。 /// /// 一期业务口径:提报人填异常时直接选处理人与复检人,提交后异常 /// 直接进入 ASSIGNED,不经认领;处理人收到创建通知后可直接处理或转交。 /// /// 本文件守护的是三类最容易悄悄退化的东西: /// ① 建单写入契约(状态 / 两个人 / 指派时间)——写错了不会报错,只会让单子躺在那里没人管; /// ② 身份边界(跨租户 / 停用账号 / 处理人与复检人同一人)——错了要么泄漏、要么复检形同虚设; /// ③ 创建通知不依赖任何配置行——这条一旦被"顺手改成走配置路径", /// 主动提报会立刻回到"建单成功但没有任何人被通知"的状态,而且全链路无一处报错。 /// /// 为什么大量采用源码文本断言CreateAsync 依赖 10+ 个仓储与 /// UserManager/FlowEngine,在单测里构造真实调用不现实;本仓对这类 /// service 内部契约的既有守护方式即为源码断言(见 S8TenantOnlyExceptionScopeTests / /// S8SysUserOnlyIdentityTests)。能纯函数验证的(状态机)一律用真实调用。 /// public class S8ManualDirectAssignTests { private static readonly string PluginRoot = Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP")); private static readonly string WebRoot = Path.GetFullPath(Path.Combine(PluginRoot, "../../../Web")); /// 只取可执行代码行:注释里会复述旧写法与反面教材,不应算命中。 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); })); } private static string WebFile(string relativePath) { var full = Path.Combine(WebRoot, relativePath.Replace('/', Path.DirectorySeparatorChar)); Assert.True(File.Exists(full), $"前端文件不存在,路径需同步更新:{full}"); return File.ReadAllText(full); } private static string ManualReport() => CodeOnly("Service/S8/S8ManualReportService.cs"); private static string Resolver() => CodeOnly("Service/S8/S8NotificationLayerResolver.cs"); private static string ReportPage() => WebFile("src/views/aidop/s8/report/S8ManualReportPage.vue"); /// CreateAsync 方法体(到下一个 public 方法为止),避免断言误命中自动建单分支。 private static string CreateAsyncBody() { var code = ManualReport(); var start = code.IndexOf("public async Task CreateAsync", StringComparison.Ordinal); Assert.True(start > 0, "未找到 CreateAsync,方法签名可能已变更"); var next = code.IndexOf("public async Task CreateFromWatchAsync", start, StringComparison.Ordinal); Assert.True(next > start, "未找到 CreateFromWatchAsync 作为下界"); return code[start..next]; } // ============================================================ // A. 建单写入契约(T1–T5) // ============================================================ [Fact] public void T1_ManualCreate_LandsDirectlyInAssigned() { var body = CreateAsyncBody(); Assert.Contains("Status = \"ASSIGNED\"", body, StringComparison.Ordinal); // 不得再落 NEW —— 落 NEW 就意味着又要有人去认领,点对点派单当场失效。 Assert.DoesNotContain("Status = \"NEW\"", body, StringComparison.Ordinal); } [Fact] public void T2_AssigneeUserId_IsWrittenFromValidatedUser() { // 写的是校验后返回的 UserId,而不是 dto 原值 —— 后者绕过了租户/启用校验。 Assert.Contains("AssigneeUserId = assignee.UserId", CreateAsyncBody(), StringComparison.Ordinal); } [Fact] public void T3_VerifierUserId_IsWrittenFromValidatedUser() { Assert.Contains("VerifierUserId = verifier.UserId", CreateAsyncBody(), StringComparison.Ordinal); } [Fact] public void T4_AssignedAt_SharesTheSameNowAsCreatedAt() { var body = CreateAsyncBody(); Assert.Contains("AssignedAt = now", body, StringComparison.Ordinal); Assert.Contains("CreatedAt = now", body, StringComparison.Ordinal); // 复用同一个 now 而不是各自 DateTime.Now:两列相差几毫秒会让 //「建单即指派」在数据上看起来像「建完之后又被指派了一次」。 Assert.Contains("var now = DateTime.Now;", body, StringComparison.Ordinal); } [Fact] public void T5_ReporterUserId_StaysCurrentSysUser() { var body = CreateAsyncBody(); Assert.Contains("ReporterUserId = currentUserId", body, StringComparison.Ordinal); Assert.Contains("_userManager.UserId", body, StringComparison.Ordinal); } // ============================================================ // B. 必填与身份规则(T6–T11) // ============================================================ [Theory] [InlineData("dto.AssigneeUserId is not > 0", "处理人")] // T6 [InlineData("dto.VerifierUserId is not > 0", "复检人")] // T7 public void T6_T7_AssigneeAndVerifier_AreRequired(string condition, string subject) { var body = CreateAsyncBody(); Assert.Contains(condition, body, StringComparison.Ordinal); Assert.Contains(subject, body, StringComparison.Ordinal); } [Fact] public void T8_Description_IsRequiredOnTheServerToo() { // 只在前端拦是不够的:直连 API 仍可提交空描述, // 而"哪里出了什么问题"正是主动提报唯一不可替代的信息。 Assert.Contains("string.IsNullOrWhiteSpace(dto.Description)", CreateAsyncBody(), StringComparison.Ordinal); } [Fact] public void T9_AssigneeEqualsVerifier_IsRejected() { var body = CreateAsyncBody(); Assert.Contains("dto.AssigneeUserId == dto.VerifierUserId", body, StringComparison.Ordinal); // 自己处理完自己复检,复检就只是个仪式。 Assert.Contains("复检失去意义", body, StringComparison.Ordinal); } [Fact] public void T10_T11_ReporterMayBeAssigneeOrVerifier() { var body = CreateAsyncBody(); // 一期明确允许自报自处理 / 自报自复检,不得出现针对提报人的比较。 // 若哪天要禁止,必须是显式业务决定,而不是顺手加进来的一行。 Assert.DoesNotContain("currentUserId == dto.AssigneeUserId", body, StringComparison.Ordinal); Assert.DoesNotContain("currentUserId == dto.VerifierUserId", body, StringComparison.Ordinal); Assert.DoesNotContain("dto.AssigneeUserId == currentUserId", body, StringComparison.Ordinal); Assert.DoesNotContain("dto.VerifierUserId == currentUserId", body, StringComparison.Ordinal); } // ============================================================ // C. 账号有效性(T12–T16) // // 跨租户 / 停用 / 不存在三种情形共用同一个校验器: // S8UserScopeValidator.EnsureActiveUserAsync 内部按 // (TenantId == tenantId && Status == Enable) 取人,取不到即抛。 // 因此这里守护的是「确实委派给了它」,而不是重新实现一遍判据。 // ============================================================ [Fact] public void T12_T13_T14_T15_T16_BothUsers_GoThroughSharedScopeValidator() { var body = CreateAsyncBody(); Assert.Contains("_userScope.EnsureActiveUserAsync", body, StringComparison.Ordinal); Assert.Contains("\"处理人\"", body, StringComparison.Ordinal); Assert.Contains("\"复检人\"", body, StringComparison.Ordinal); // 两次调用,一人一次 —— 只校验其中一个是本类问题里最容易漏的那种。 var occurrences = body.Split("_userScope.EnsureActiveUserAsync").Length - 1; Assert.True(occurrences >= 2, $"处理人与复检人都必须校验,当前只有 {occurrences} 次调用"); } [Fact] public void T12b_ManualReport_NeverHandRollsItsOwnUserQuery() { var code = ManualReport(); // 手写「本租户 + 启用」的 SysUser 查询 = 第二套身份 Authority, // 两套判据迟早分叉,且分叉只表现为"某些人莫名其妙选不到/收不到"。 // 该约束同时被 S8SysUserOnlyIdentityTests 守护,这里再钉一次是因为 // 本批次正是最有动机去手写这条查询的时刻。 Assert.DoesNotContain("Status == StatusEnum.Enable", code, StringComparison.Ordinal); Assert.DoesNotContain("AdoS0EmployeeMaster", code, StringComparison.Ordinal); Assert.DoesNotContain("GetEmployeeSysUserId", code, StringComparison.Ordinal); } // ============================================================ // D. 状态机(T17–T19)——这三条是真实调用,不是文本断言 // ============================================================ [Fact] public void T17_OnceAssigned_ClaimIsNoLongerPossible() { // 建单即 ASSIGNED 之后,认领这个动作在状态机层面就不成立了: // 边表里没有 ASSIGNED → ASSIGNED,ClaimAsync 会在状态守卫处直接拒绝, // 根本走不到那条「AssigneeUserId == null」的原子 UPDATE。 Assert.False(S8StatusRules.IsAllowedTransition("ASSIGNED", "ASSIGNED")); // 自动异常仍然从 NEW 开始,认领路径必须完好。 Assert.True(S8StatusRules.IsAllowedTransition("NEW", "ASSIGNED")); } [Fact] public void T18_Assigned_CanStartProgress() { Assert.True(S8StatusRules.IsAllowedTransition("ASSIGNED", "IN_PROGRESS")); } [Fact] public void T19_Assigned_IsInTransferWhitelist() { var code = CodeOnly("Service/S8/S8TaskFlowService.cs"); // 转派白名单必须仍含 ASSIGNED,否则「派错了人可以转交」这条一期承诺当场落空。 var idx = code.IndexOf("allowedStatuses", StringComparison.Ordinal); Assert.True(idx > 0, "未找到 TransferAsync 的状态白名单"); var window = code.Substring(idx, Math.Min(200, code.Length - idx)); Assert.Contains("\"ASSIGNED\"", window, StringComparison.Ordinal); } [Fact] public void T20_PrewrittenVerifier_DoesNotShortCircuitSubmitVerification() { var code = CodeOnly("Service/S8/S8TaskFlowService.cs"); // 提交复检仍必须走资格校验:建单时预写的 verifier 只是默认值, // 不是"已经批准过"的凭据。绕过它等于让提报人单方面决定谁能复核。 Assert.Contains("_verifierEligibility.CheckAsync", code, StringComparison.Ordinal); Assert.Contains("只有当前处理人才能提交复检", code, StringComparison.Ordinal); } // ============================================================ // E. 创建通知(T21–T25) // ============================================================ [Fact] public void T21_CreatedNotification_TargetsTheAssigneeOnly() { var body = CreateAsyncBody(); var code = ManualReport(); Assert.Contains("TryDispatchCreatedToAssigneeAsync(entity, assignee)", body, StringComparison.Ordinal); Assert.Contains("new[] { assignee.UserId }", code, StringComparison.Ordinal); // 复检人此刻还轮不到动手,通知他只会制造一条无法行动的消息。 Assert.DoesNotContain("verifier.UserId }", code, StringComparison.Ordinal); } [Fact] public void T22_T23_CreatedNotification_NeedsNoConfigRowAndNoSourceRule() { var code = ManualReport(); // 走显式收件人入口。配置路径 DispatchByLayerAsync 会按 // (租户, rule_code, 事件) 查配置行,而主动提报的 rule_code 恒为 NULL、 // 兜底键 '*' 至今没有写入方 —— 用它等于永远发不出去。 Assert.Contains("DispatchToExplicitUsersAsync", code, StringComparison.Ordinal); Assert.DoesNotContain("DispatchByLayerAsync", code, StringComparison.Ordinal); var resolver = Resolver(); // 显式路径不得回头去解析配置,否则又会被 Source == "NONE" 挡掉。 var start = resolver.IndexOf("DispatchToExplicitUsersAsync", StringComparison.Ordinal); Assert.True(start > 0); var end = resolver.IndexOf("private async Task DispatchToUsersAsync", start, StringComparison.Ordinal); Assert.True(end > start); var method = resolver[start..end]; Assert.DoesNotContain("_recipientResolver.ResolveAsync", method, StringComparison.Ordinal); Assert.Contains("\"EXPLICIT\"", method, StringComparison.Ordinal); } [Fact] public void T24_NoLegacyNotificationRevived() { var code = ManualReport(); Assert.DoesNotContain("ado_s8_notification_layer", code, StringComparison.Ordinal); Assert.DoesNotContain("AdoS8NotificationLayer", code, StringComparison.Ordinal); // 也不得偷偷引入租户级通配配置。 Assert.DoesNotContain("TenantDefaultRuleCode", code, StringComparison.Ordinal); } [Fact] public void T25_NotificationFailure_NeverRollsBackTheException() { var code = ManualReport(); var body = CreateAsyncBody(); // 通知必须在事务之后发:异常已经建成,通知发不出去是通知的问题。 var tranEnd = body.IndexOf("}, ex => throw ex);", StringComparison.Ordinal); var dispatchAt = body.IndexOf("TryDispatchCreatedToAssigneeAsync", StringComparison.Ordinal); Assert.True(tranEnd > 0 && dispatchAt > tranEnd, "创建通知必须在事务提交之后发起,否则通知失败会连带回滚已经成立的异常"); // 且自身吞异常只记 Warning。 var m = code.IndexOf("private async Task TryDispatchCreatedToAssigneeAsync", StringComparison.Ordinal); Assert.True(m > 0); var method = code.Substring(m, Math.Min(2000, code.Length - m)); Assert.Contains("catch (Exception ex)", method, StringComparison.Ordinal); Assert.Contains("_logger.LogWarning", method, StringComparison.Ordinal); } // ============================================================ // F. 前端契约(T26–T29) // ============================================================ [Fact] public void T26_ReportPage_NoLongerOffersTemplates() { var page = ReportPage(); Assert.DoesNotContain("提报模板", page, StringComparison.Ordinal); Assert.DoesNotContain("reportTemplates", page, StringComparison.Ordinal); Assert.DoesNotContain("applyTemplate", page, StringComparison.Ordinal); Assert.DoesNotContain("loadTemplates", page, StringComparison.Ordinal); } [Fact] public void T27_DepartmentsNoLongerSilentlyDefaultToFirstOption() { var page = ReportPage(); // 静默选中第一个部门,比不填更危险:用户不会注意到自己提交了一个错误归属。 // // 断言的是**赋值形状**而不是裸 token「departments[0]」:源码里留了一条 // 「这里不再回落到 departments[0]」的说明性注释,用裸 token 会命中它自己 —— // 那样这条测试就变成了「注释里不许提这件事」,而不是「代码里不许这么做」。 Assert.DoesNotContain("departments[0].value", page, StringComparison.Ordinal); Assert.Contains("请选择发生部门", page, StringComparison.Ordinal); Assert.Contains("请选择责任部门", page, StringComparison.Ordinal); } [Fact] public void T28_T29_CandidatesAreNarrowedByAction() { var page = ReportPage(); // 页面上能选到的人必须与后端会放行的人一致,否则用户会选中一个 // 看起来正常、提交却被拒的账号。 Assert.Contains("EXCEPTION_START", page, StringComparison.Ordinal); Assert.Contains("EXCEPTION_VERIFY", page, StringComparison.Ordinal); Assert.Contains("assigneeUserId", page, StringComparison.Ordinal); Assert.Contains("verifierUserId", page, StringComparison.Ordinal); } [Fact] public void T29b_FrontendBlocksSameAssigneeAndVerifier() { Assert.Contains("处理人与复检人不能为同一账号", ReportPage(), StringComparison.Ordinal); } // ============================================================ // G. 自动 Rule 零回归(T30) // ============================================================ [Fact] public void T30_AutoCreationPaths_StillStartAtNew() { var code = ManualReport(); // 自动建单没有"提报人指定处理人"这回事,必须仍从 NEW 起、仍走认领。 var watchAt = code.IndexOf("public async Task CreateFromWatchAsync", StringComparison.Ordinal); Assert.True(watchAt > 0); var autoPart = code[watchAt..]; Assert.Contains("Status = \"NEW\"", autoPart, StringComparison.Ordinal); // 自动路径不得被顺手改成点对点派单。 Assert.DoesNotContain("AssigneeUserId = assignee.UserId", autoPart, StringComparison.Ordinal); Assert.DoesNotContain("MANUAL_ASSIGN", autoPart, StringComparison.Ordinal); } [Fact] public void T30b_ClaimPathIsUntouched() { var code = CodeOnly("Service/S8/S8TaskFlowService.cs"); // 一期不废弃自助认领:自动异常仍靠它流转。 Assert.Contains("_claimEligibility.CheckAsync", code, StringComparison.Ordinal); Assert.Contains("x.AssigneeUserId == null", code, StringComparison.Ordinal); } // ============================================================ // H. DTO / 时间线 // ============================================================ [Fact] public void Dto_CarriesBothPeopleAsNullableLongs() { var t = typeof(AdoS8ManualReportCreateDto); foreach (var name in new[] { "AssigneeUserId", "VerifierUserId" }) { var p = t.GetProperty(name, BindingFlags.Public | BindingFlags.Instance); Assert.NotNull(p); // 可空是为了契约兼容(老调用方不传不会 400 在模型绑定层), // 业务必填由 CreateAsync 的 guard 兜底 —— 见 T6/T7。 Assert.Equal(typeof(long?), p!.PropertyType); } } [Fact] public void Timeline_RecordsAssignmentSeparatelyFromCreate() { var body = CreateAsyncBody(); Assert.Contains("\"MANUAL_ASSIGN\"", body, StringComparison.Ordinal); // 不得复用 CLAIM:CLAIM 是「处理人自己把单子捞走」, // 这里是「提报人把单子指给别人」,责任主体正好相反。 Assert.DoesNotContain("\"CLAIM\"", body, StringComparison.Ordinal); } [Fact] public void Timeline_CreateNoLongerClaimsTheRowWasEverNew() { var body = CreateAsyncBody(); // 单据从未处于 NEW —— CREATE 那条若仍写 ToStatus="NEW", // 时间线就记录了一个真实世界不存在的状态,而 from/to 是对外暴露的字段。 var createAt = body.IndexOf("ActionCode = \"CREATE\"", StringComparison.Ordinal); Assert.True(createAt > 0); var window = body.Substring(createAt, Math.Min(400, body.Length - createAt)); Assert.Contains("ToStatus = \"ASSIGNED\"", window, StringComparison.Ordinal); Assert.DoesNotContain("ToStatus = \"NEW\"", window, StringComparison.Ordinal); } }