using Admin.NET.Plugin.AiDOP.Const.S8;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S8;
///
/// S8-LEGACY-NOTIFY-DECOMMISSION-1:旧通知分层的 Runtime 退役证明。
///
/// 本文件要钉死的一件事:通知收件人从此只有一个来源 ——
/// ado_s8_notification_recipient(Rule + Event + RecipientType)。
/// 没配置就是不发;不会有第二套 (scene, severity, level) 语义在背后接管。
///
/// 为什么这值得一整个文件的守卫:双 Authority 是本模块最难自查的故障形态。
/// 管理员在规则弹窗里把收件人删干净,通知却照发 —— 因为旧表还在兜底,
/// 而那批人在新页面上根本看不见。退役之后,缺口会以「0 收件人 + Warning」暴露出来,
/// 而不是被悄悄补上。后来者若想恢复 fallback「让测试变绿」,请先读这段。
///
public class S8LegacyNotifyDecommissionTests
{
private static readonly string PluginRoot =
Path.GetFullPath(Path.Combine(AppContext.BaseDirectory, "../../../../Admin.NET.Plugin.AiDOP"));
private static string Read(string relative)
{
var full = Path.Combine(PluginRoot, relative.Replace('/', Path.DirectorySeparatorChar));
Assert.True(File.Exists(full), $"源码文件不存在,路径需同步更新:{full}");
return File.ReadAllText(full);
}
/// 去注释 —— 注释里提到某个词不等于代码在用它。
private static string CodeOnly(string relative) =>
string.Join('\n', Read(relative).Split('\n').Where(l =>
{
var t = l.TrimStart();
return !t.StartsWith("///", StringComparison.Ordinal) && !t.StartsWith("//", StringComparison.Ordinal);
}));
private const string Dispatcher = "Service/S8/S8NotificationLayerResolver.cs";
private const string Recipient = "Service/S8/S8NotificationRecipientResolver.cs";
private const string LegacySeed = "SeedData/S8NotificationLayerSeedData.cs";
// ══════════════════════ T1–T4 · 退役核心 ══════════════════════
/// T1:NEW 命中 → 正常解析并发送。
[Fact]
public void T1_NewRecipientHit_IsDispatched()
{
var code = CodeOnly(Dispatcher);
Assert.Contains("_recipientResolver.ResolveAsync(input.TenantId, input.EventCode!, input.ExceptionRef)", code);
Assert.Contains("await DispatchToUsersAsync(input, resolution);", code);
}
///
/// T2:NEW 不存在 → 不得调用 Legacy resolver,直接返回。
/// 这是退役的核心断言:miss 之后没有第二条路。
///
[Fact]
public void T2_NewRecipientMiss_DoesNotCallLegacy()
{
var code = CodeOnly(Dispatcher);
Assert.Contains("if (resolution.Source == \"NONE\")", code);
// NONE 分支之后到方法结束之间,不得出现任何旧表读取
var idx = code.IndexOf("if (resolution.Source == \"NONE\")", StringComparison.Ordinal);
Assert.True(idx > 0);
var tail = code[idx..];
foreach (var legacy in new[] { "AdoS8NotificationLayer", "S8NotificationLayerMerge", "_layerRep", "layers" })
Assert.DoesNotContain(legacy, tail);
}
///
/// T3:NEW 配了但解析出 0 人 → 保持既有 fail-loud(Warning + return),不回落。
/// 本批刻意不重构这一点:0 收件人必须可观测,而不是被旧配置补上。
///
[Fact]
public void T3_ConfiguredButEmpty_StaysFailLoudWithoutFallback()
{
var code = CodeOnly(Dispatcher);
Assert.Contains("resolution.UserIds.Count == 0", code);
Assert.Contains("S8RecipientDispatch: resolved 0 recipients", code);
// ConfiguredButEmpty 语义在收件人解析侧同样保留
var recipient = CodeOnly(Recipient);
Assert.Contains("ConfiguredButEmpty", recipient);
Assert.Contains("s8_notify_recipients_empty", recipient);
}
///
/// T4:本批最关键的退役证明 —— 即便旧表里躺着能匹配的数据,
/// NEW miss 时也绝不会去用它。
///
/// 判据是「派发入口对旧表零引用」而不是「查询条件变了」:
/// 只要还留着一行查询,将来某次「顺手加个开关」就能把它接回来。
///
[Fact]
public void T4_LegacyRowsAreNeverConsultedEvenWhenTheyWouldMatch()
{
var code = CodeOnly(Dispatcher);
foreach (var legacy in new[]
{
"AdoS8NotificationLayer", "S8NotificationLayerMerge",
"_layerRep", "_roleResolver", "SceneCode == input.SceneCode",
"x.Severity == severity", "S8ConfigScope.GlobalTenantId",
})
Assert.DoesNotContain(legacy, code);
}
/// T5:Legacy Seed 不再生成任何分层数据。
[Fact]
public void T5_LegacySeed_ProducesNothing()
{
var seed = CodeOnly(LegacySeed);
Assert.Contains("HasData() => Array.Empty()", seed);
// 原来的 scene→role 映射必须整体消失,否则等于给恢复留了入口
foreach (var gone in new[]
{
"ROLE_ORDER_PLANNER", "ROLE_PRODUCTION_PLANNER", "ROLE_QC",
"ROLE_WH_INBOUND", "ROLE_PURCHASER", "L1_OPERATOR", "sceneRoleMap",
})
Assert.DoesNotContain(gone, seed);
}
// ══════════════════════ T6–T9 · 新链路不得回归 ══════════════════════
/// T6–T9:五种 RecipientType 的解析全部保留。
[Theory]
[InlineData("HandlerPool", "_handlerPool.GetMemberIdsAsync")]
[InlineData("Assignee", "e.AssigneeUserId is > 0")]
[InlineData("Reviewer", "e.VerifierUserId is > 0")]
[InlineData("EscalationPool", "S8ResponsibilityType.Escalation")]
[InlineData("SpecificUser", "S8RoleResolver.SplitTokens(row.RecipientUserIds)")]
public void T6_T9_AllRecipientTypesStillResolve(string type, string marker)
{
var code = CodeOnly(Recipient);
Assert.Contains($"case S8RecipientType.{type}:", code);
Assert.Contains(marker, code);
}
/// HANDLER_POOL / ESCALATION_POOL 仍从 Rule 责任池解析,未被改成别的来源。
[Fact]
public void ResponsibilityPools_RemainTheSourceForPoolRecipients()
{
var code = CodeOnly(Recipient);
Assert.Contains("IS8RuleHandlerPoolReader", code);
Assert.Contains("IS8RuleResponsibilityReader", code);
Assert.Contains("_pools.GetMemberIdsAsync(", code);
// 绝不从旧 target_role / L1-L3 推责任
foreach (var banned in new[] { "TargetRoleIds", "L1_OPERATOR", "L2_MANAGER", "L3_DIRECTOR" })
Assert.DoesNotContain(banned, code);
}
/// T10:租户隔离不回归 —— 收件人最终仍过一遍同租户 + 启用校验。
[Fact]
public void T10_TenantIsolationNotRegressed()
{
var code = CodeOnly(Recipient);
Assert.Contains("_userScope.ValidateUsersAsync(tenantId, ids)", code);
Assert.Contains("x.TenantId == tenantId", code);
var dispatcher = CodeOnly(Dispatcher);
Assert.Contains("input.TenantId", dispatcher);
}
///
/// T11:legacy backend 定义仍在(P1-C3 才清),但 Runtime 消费者 = 0。
///
[Fact]
public void T11_LegacyBackendDefinitionsKept_ButZeroRuntimeConsumer()
{
foreach (var kept in new[]
{
"Entity/S8/AdoS8NotificationLayer.cs",
"Service/S8/S8NotificationLayerService.cs",
"Service/S8/S8NotificationLayerMerge.cs",
"Controllers/S8/AdoS8ConfigNotificationLayersController.cs",
"SeedData/S8NotificationLayerSeedData.cs",
})
Assert.True(File.Exists(Path.Combine(PluginRoot, kept.Replace('/', Path.DirectorySeparatorChar))),
$"legacy backend 定义被误删(应留给 P1-C3):{kept}");
// 唯一的通知派发入口对旧表零引用
Assert.DoesNotContain("AdoS8NotificationLayer", CodeOnly(Dispatcher));
}
///
/// T12:人工提报未被擅自改成 '*' 方案。
///
/// 已知缺口(本批明确不修):主动提报的异常 SourceRuleCode 为 NULL,
/// 新模型会以 rule_code='*' 为键查找,而 '*' 目前没有任何业务写入路径。
/// fallback 切断后,这类异常的后续事件通知暂时无收件人来源。
///
/// 该缺口由「主动提报通知契约」独立梳理。本守卫防的是两种走偏:
/// ① 为了补缺口而改 Manual Report 去塞一个假 RuleCode;
/// ② 为了让测试变绿而把 legacy fallback 接回来。
///
[Fact]
public void T12_ManualReportContract_IsUntouchedAndGapIsAcknowledged()
{
var manual = CodeOnly("Service/S8/S8ManualReportService.cs");
// 手工建单仍不写 SourceRuleCode —— 没有被塞进一个假规则码
var createIdx = manual.IndexOf("SourceType = \"MANUAL\"", StringComparison.Ordinal);
Assert.True(createIdx > 0, "未找到手工提报的建单分支");
var block = manual[Math.Max(0, createIdx - 1500)..Math.Min(manual.Length, createIdx + 1500)];
Assert.DoesNotContain("SourceRuleCode =", block);
// 也没有借机新增 '*' 的写入能力
var configSvc = CodeOnly("Service/S8/S8NotificationRecipientConfigService.cs");
Assert.DoesNotContain("TenantDefaultRuleCode", configSvc);
// '*' 仍然只有读侧引用(resolver 内),没有任何写入者
Assert.Contains("TenantDefaultRuleCode", CodeOnly(Recipient));
}
///
/// 目录仍是 PROVISIONING 定位,没有被悄悄升格成 Runtime Authority。
/// 判据:派发链路(入口 + 收件人解析)都不查 DefaultRecipientByEvent。
///
[Fact]
public void DefaultRecipientCatalog_StaysProvisioningOnly()
{
foreach (var runtime in new[] { Dispatcher, Recipient })
Assert.DoesNotContain("DefaultRecipientByEvent", CodeOnly(runtime));
// 目录本身与写入侧仍在
Assert.Contains("DefaultRecipientByEvent", Read("Const/S8/S8NotificationCatalog.cs"));
Assert.Contains("ResolveDefaultRecipientType", Read("Service/S8/S8NotificationRecipientConfigService.cs"));
Assert.Equal(7, S8NotificationCatalog.Events.Count);
}
}