| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440 |
- 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;
- /// <summary>
- /// S8-MANUAL-DIRECT-ASSIGN-1:主动提报「点对点派单」契约。
- ///
- /// <para><b>一期业务口径</b>:提报人填异常时直接选处理人与复检人,提交后异常
- /// <b>直接进入 ASSIGNED</b>,不经认领;处理人收到创建通知后可直接处理或转交。</para>
- ///
- /// <para><b>本文件守护的是三类最容易悄悄退化的东西</b>:
- /// ① 建单写入契约(状态 / 两个人 / 指派时间)——写错了不会报错,只会让单子躺在那里没人管;
- /// ② 身份边界(跨租户 / 停用账号 / 处理人与复检人同一人)——错了要么泄漏、要么复检形同虚设;
- /// ③ 创建通知<b>不依赖任何配置行</b>——这条一旦被"顺手改成走配置路径",
- /// 主动提报会立刻回到"建单成功但没有任何人被通知"的状态,而且全链路无一处报错。</para>
- ///
- /// <para><b>为什么大量采用源码文本断言</b>:<c>CreateAsync</c> 依赖 10+ 个仓储与
- /// <c>UserManager</c>/<c>FlowEngine</c>,在单测里构造真实调用不现实;本仓对这类
- /// service 内部契约的既有守护方式即为源码断言(见 <c>S8TenantOnlyExceptionScopeTests</c> /
- /// <c>S8SysUserOnlyIdentityTests</c>)。能纯函数验证的(状态机)一律用真实调用。</para>
- /// </summary>
- 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"));
- /// <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);
- }));
- }
- 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");
- /// <summary><c>CreateAsync</c> 方法体(到下一个 public 方法为止),避免断言误命中自动建单分支。</summary>
- private static string CreateAsyncBody()
- {
- var code = ManualReport();
- var start = code.IndexOf("public async Task<AdoS8ManualReportResultDto> CreateAsync", StringComparison.Ordinal);
- Assert.True(start > 0, "未找到 CreateAsync,方法签名可能已变更");
- var next = code.IndexOf("public async Task<AdoS8Exception> 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<AdoS8Exception> 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);
- }
- }
|