using System.Reflection;
using System.Text.RegularExpressions;
using System.Runtime.CompilerServices;
using Microsoft.AspNetCore.Mvc;
using Admin.NET.Plugin.AiDOP.Controllers.S8;
using Admin.NET.Plugin.AiDOP.Entity.S8;
using Admin.NET.Plugin.AiDOP.Infrastructure;
using Admin.NET.Plugin.AiDOP.Service.S8;
using Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S8;
///
/// S8-RULE-GOVERNANCE-BATCH3:业务侧根本不能创建 / 删除监控规则。
///
/// 本文件取代了原 S8DatasetOnlyRuleCreationTests。那个名字守护的是
/// 「创建规则时只能选治理数据集」—— 一条已经过时的口径:现在不存在"创建规则"这件事,
/// 继续用那个名字会让读者以为 Rule Builder 还在,只是被限制了选项。
///
/// 最终模型:
///
/// 业务需求 → 研发实现 S8RuleDefinition → S8RuleCatalog → Release
/// → S8RuleProvisioningService → 每租户一条 Runtime Policy(默认停用)
///
/// 用户能做的只有:调整已发布规则的运行参数、启停、预演、立即执行、暂停恢复。
///
/// 原文件中仍然有效的断言(Legacy 类型不存在 / 数据集端点只读 / Rule 01 形态可表达)
/// 已逐条迁入本文件,未丢失覆盖。
///
/// 不接 DB、不接 DI:退役守卫在**任何 DB 访问之前**抛出,因此用
/// 绕开构造函数即可驱动。
/// 若将来有人把守卫挪到 DB 访问之后,本测试会因 NullReferenceException 失败 ——
/// 这正是我们要的信号:守卫必须前置。
///
public class S8RuleCreationRetiredTests
{
private static readonly Assembly PluginAssembly = typeof(AdoS8WatchRule).Assembly;
private static S8WatchRuleService Svc() =>
(S8WatchRuleService)RuntimeHelpers.GetUninitializedObject(typeof(S8WatchRuleService));
private static readonly S8TrustedScope Scope = new(838257186181189L);
private static AdoS8WatchRule Body() => new()
{
RuleCode = "RULE_A_USER_INVENTED",
SceneCode = "S1",
RuleType = "TIMEOUT",
DatasetCode = "PURCHASE_DELIVERY",
WatchObjectType = "ORDER",
PollIntervalSeconds = 300
};
// ───────────────────────── T1:创建能力 ─────────────────────────
[Fact]
public void T1_CreateAsync_IsRetired_WithGuidanceMessage()
{
var ex = Assert.Throws(() => { _ = Svc().CreateAsync(Body(), Scope); });
Assert.Equal(S8WriteRetiredException.WatchRuleCreateMessage, ex.Message);
// 文案必须说清"新规则从哪来",否则用户只会以为接口坏了。
Assert.Contains("系统版本定义", ex.Message);
Assert.DoesNotContain("unsupported", ex.Message, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("unknown", ex.Message, StringComparison.OrdinalIgnoreCase);
// 与"整实体更新退役"是不同的事,文案不得混用。
Assert.NotEqual(S8WriteRetiredException.WatchRuleUpdateMessage, ex.Message);
}
/// 入参是否"合法"与能力是否存在无关:任何 body 都必须 410,且零 DB 访问。
[Fact]
public void T1_CreateAsync_IsRetired_RegardlessOfPayload()
{
Assert.Throws(() => { _ = Svc().CreateAsync(new AdoS8WatchRule(), Scope); });
var wellFormed = Body();
wellFormed.RuleCode = "RULE_S4_PURCHASE_DELIVERY_DATE_DELAY"; // 连"合法"的编码也不行
Assert.Throws(() => { _ = Svc().CreateAsync(wellFormed, Scope); });
}
[Fact]
public void T1_CreateEndpoint_Returns410_AndTouchesNoScope()
{
var action = Assert.Single(Actions(typeof(AdoS8ConfigWatchRulesController), "CreateAsync"));
// 墓碑而非删 route:直接删会让遗留调用方拿到 405(该路径上还有 GET),
// 而 405 说的是"方法不对",会让人以为换个动词就能建。
Assert.Single(action.GetCustomAttributes());
Assert.NotEmpty(action.GetCustomAttributes());
// 同步返回:能力已退役,不该为构造一个用不到的 scope 去 await 组织库查询。
Assert.Equal(typeof(IActionResult), action.ReturnType);
}
// ───────────────────────── T2:删除能力 ─────────────────────────
[Fact]
public void T2_DeleteAsync_IsRetired_AndPointsToDisable()
{
var ex = Assert.Throws(() => { _ = Svc().DeleteAsync(1329909460023L, Scope); });
Assert.Equal(S8WriteRetiredException.WatchRuleDeleteMessage, ex.Message);
Assert.Contains("停用", ex.Message); // 必须给出正确的替代动作
}
[Theory]
[InlineData(0L)]
[InlineData(-1L)]
[InlineData(long.MaxValue)]
public void T2_DeleteAsync_IsRetired_RegardlessOfId(long id)
{
// 任意 id(含越权 id)一律 410 而非 404:
// 「这个能力没了」优先于「这条记录不属于你」,避免越权探测反推他租户数据是否存在。
Assert.Throws(() => { _ = Svc().DeleteAsync(id, Scope); });
}
[Fact]
public void T2_DeleteEndpoint_Returns410()
{
var action = Assert.Single(Actions(typeof(AdoS8ConfigWatchRulesController), "DeleteAsync"));
Assert.Single(action.GetCustomAttributes());
Assert.NotEmpty(action.GetCustomAttributes());
Assert.Equal(typeof(IActionResult), action.ReturnType);
}
// ───────────────────────── T3 / T4 / T14 / T15:Wizard 全链消失 ─────────────────────────
///
/// 向导后端整体退役。它的唯一出口是"生成规则",而创建规则已经不存在,
/// 草稿也就失去了全部意义 —— 不为"以后可能用"保留死代码。
///
[Theory]
[InlineData("Admin.NET.Plugin.AiDOP.Controllers.S8.AdoS8ConfigDraftsController")]
[InlineData("Admin.NET.Plugin.AiDOP.Service.S8.S8ConfigDraftService")]
[InlineData("Admin.NET.Plugin.AiDOP.Dto.S8.AdoS8ConfigDraftCreateDto")]
[InlineData("Admin.NET.Plugin.AiDOP.Dto.S8.AdoS8ConfigDraftUpdateDto")]
[InlineData("Admin.NET.Plugin.AiDOP.Dto.S8.AdoS8ConfigDraftGenerateRuleDto")]
[InlineData("Admin.NET.Plugin.AiDOP.Dto.S8.AdoS8ConfigDraftDetailDto")]
public void T3_T4_WizardBackendType_NoLongerExists(string fullName)
{
Assert.Null(PluginAssembly.GetType(fullName, throwOnError: false));
}
/// T14:整个程序集里不得再有任何 wizard-drafts 路由。
[Fact]
public void T14_NoWizardDraftRouteRemains()
{
var offenders = PluginAssembly.GetTypes()
.Where(t => typeof(ControllerBase).IsAssignableFrom(t))
.SelectMany(t => t.GetCustomAttributes().Select(r => r.Template ?? string.Empty))
.Where(t => t.Contains("wizard", StringComparison.OrdinalIgnoreCase)
|| t.Contains("draft", StringComparison.OrdinalIgnoreCase))
.ToArray();
Assert.Empty(offenders);
}
///
/// T15:实体与表按既有口径 KEEP_TEMPORARILY(先退役 API、后物理清理)。
///
/// Batch 4 修正:本用例原先断言「除实体自身外无任何类型消费它」,
/// 那是基于 Batch 3 的一个错误结论 —— 当时审计认为草稿表只服务规则向导。
/// 实际上主动提报页把 mechanism = MANUAL_REPORT 的行当作提报模板在用,
/// Batch 3 把整个 Controller 删掉后该功能静默失效。
///
/// 现在的口径是:只允许只读的模板服务消费它,且该服务不得有任何写能力。
/// 这比"零消费方"更准确,也仍然挡住"借草稿表复活建规则"。
///
[Fact]
public void T15_ConfigDraftEntity_IsConsumedOnlyByReadOnlyTemplateService()
{
var entity = PluginAssembly.GetType("Admin.NET.Plugin.AiDOP.Entity.S8.AdoS8ConfigDraft", throwOnError: false);
Assert.NotNull(entity); // 表还在,等后续 schema cleanup
var consumers = PluginAssembly.GetTypes()
.Where(t => t != entity && !t.Name.Contains("Startup", StringComparison.Ordinal))
// 排除编译器生成的 async 状态机:它们把外层 this 与局部变量提升成字段,
// 因此会连带"持有"仓储类型,但那不是一个独立的消费方。
.Where(t => !t.IsDefined(typeof(CompilerGeneratedAttribute), inherit: false))
.Where(t => t.GetFields(BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public)
.Any(f => f.FieldType.IsGenericType
&& f.FieldType.GetGenericArguments().Contains(entity)))
.Select(t => t.Name)
.ToArray();
Assert.Equal(new[] { "S8ReportTemplateService" }, consumers);
}
/// 模板服务必须是纯读:出现任何写动词都意味着建规则那条路可能被绕回来。
[Fact]
public void T15_ReportTemplateService_IsReadOnly()
{
var t = PluginAssembly.GetType("Admin.NET.Plugin.AiDOP.Service.S8.S8ReportTemplateService", throwOnError: true)!;
var publicMethods = t.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Select(m => m.Name).OrderBy(n => n, StringComparer.Ordinal).ToArray();
Assert.Equal(new[] { "GetAsync", "ListAsync" }, publicMethods);
foreach (var verb in new[] { "Create", "Update", "Delete", "Save", "Insert", "Generate", "Publish" })
Assert.DoesNotContain(publicMethods, n => n.StartsWith(verb, StringComparison.Ordinal));
}
/// 模板端点只有 GET,且用只读权限码而非规则配置权限码。
[Fact]
public void T15_ReportTemplateEndpoints_AreGetOnly()
{
var controller = PluginAssembly.GetType(
"Admin.NET.Plugin.AiDOP.Controllers.S8.AdoS8ConfigReportTemplatesController", throwOnError: true)!;
var actions = controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly).ToArray();
Assert.NotEmpty(actions);
foreach (var m in actions)
{
Assert.NotEmpty(m.GetCustomAttributes());
Assert.Empty(m.GetCustomAttributes());
Assert.Empty(m.GetCustomAttributes());
Assert.Empty(m.GetCustomAttributes());
Assert.Empty(m.GetCustomAttributes());
}
var route = Assert.Single(controller.GetCustomAttributes()).Template ?? string.Empty;
Assert.DoesNotContain("draft", route, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("wizard", route, StringComparison.OrdinalIgnoreCase);
}
// ───────────────────────── T5 / T13:其余入口 ─────────────────────────
/// T5:CreateAsync 不再是业务生产写路径 —— 名字还在,但恒抛退役异常。
[Fact]
public void T5_CreateAsync_IsNotAProductionWritePath()
{
var m = typeof(S8WatchRuleService).GetMethod("CreateAsync",
new[] { typeof(AdoS8WatchRule), typeof(S8TrustedScope) });
Assert.NotNull(m);
// 保留方法名而非删掉:断言"恒抛退役异常"比断言"名字不存在"更能防住有人换个名字复活它。
Assert.Throws(() => { _ = Svc().CreateAsync(Body(), Scope); });
// 无作用域重载不得存在。
Assert.Null(typeof(S8WatchRuleService).GetMethod("CreateAsync", new[] { typeof(AdoS8WatchRule) }));
}
/// T13:TestAsync 已物理移除(能力被 GET /{id}/preview 完全覆盖)。
[Fact]
public void T13_TestEndpointAndServiceMethod_AreGone()
{
Assert.Null(typeof(S8WatchRuleService).GetMethods().FirstOrDefault(m => m.Name == "TestAsync"));
Assert.Empty(Actions(typeof(AdoS8ConfigWatchRulesController), "TestAsync"));
}
// ───────────────────────── T6:唯一合法运行时创建源 ─────────────────────────
///
/// T6:Provisioning 是唯一合法的 runtime create source。
///
/// 注意这条守卫要表达的不是"任何 Insert 都非法" —— 那会误伤系统供给路径。
/// 要表达的是:用户/API 创建 = 禁止,代码供给 = 允许。
/// 因此用源码扫描找出所有对 AdoS8WatchRule 的 Insert 语句,
/// 断言它们只出现在 S8RuleProvisioningService 里。
///
[Fact]
public void T6_OnlyProvisioningInsertsWatchRules()
{
var root = PluginSourceRoot();
var offenders = new List();
// 只认「声明为 SqlSugarRepository 的那个字段上的插入」。
// 粗放地扫 AsInsertable 会误伤同一文件里对别的实体的写入 ——
// S8WatchSchedulerService 就同时持有 watch rule 仓储(只读)与 detection state 仓储(要写)。
var fieldDecl = new Regex(
@"SqlSugarRepository\s+(_[A-Za-z0-9_]+)", RegexOptions.Compiled);
foreach (var file in Directory.EnumerateFiles(root, "*.cs", SearchOption.AllDirectories))
{
var name = Path.GetFileName(file);
if (name == "S8RuleProvisioningService.cs") continue; // 唯一合法系统写路径
var code = ExecutableLines(File.ReadAllText(file));
var fields = fieldDecl.Matches(code).Select(m => m.Groups[1].Value).Distinct().ToArray();
foreach (var line in code.Split('\n'))
{
var trimmed = line.Trim();
// 形式一:显式泛型 Insertable
if (trimmed.Contains("Insertable", StringComparison.Ordinal))
offenders.Add($"{name}: {trimmed}");
// 形式二:在 watch rule 仓储字段上调用 AsInsertable / InsertAsync
foreach (var f in fields)
{
if (trimmed.Contains($"{f}.AsInsertable", StringComparison.Ordinal)
|| trimmed.Contains($"{f}.InsertAsync", StringComparison.Ordinal))
offenders.Add($"{name}: {trimmed}");
}
}
}
Assert.True(offenders.Count == 0,
"只有 S8RuleProvisioningService 允许创建监控规则运行策略行,以下位置出现了额外的写入:\n "
+ string.Join("\n ", offenders));
}
/// 反向对照:守卫不是"恒为空"——供给服务里确实存在那唯一一处合法插入。
[Fact]
public void T6_ProvisioningItselfDoesInsert()
{
var code = File.ReadAllText(Path.Combine(PluginSourceRoot(), "Service", "S8", "S8RuleProvisioningService.cs"));
Assert.Contains("_rep.AsInsertable(row)", code);
}
// ───────────────────────── 迁自原文件:仍然有效的断言 ─────────────────────────
/// Legacy 取数 / 数据源相关类型全部不存在(与 S8LegacySymbolsRemovedTests 互补)。
[Theory]
[InlineData("Admin.NET.Plugin.AiDOP.Entity.S8.AdoS8DataSource")]
[InlineData("Admin.NET.Plugin.AiDOP.Service.S8.S8DataSourceService")]
[InlineData("Admin.NET.Plugin.AiDOP.Controllers.S8.AdoS8ConfigDataSourcesController")]
[InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8DataSourceRowLoader")]
[InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8SqlSugarScopeFactory")]
[InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8LegacySqlDataProvider")]
[InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.IS8LegacySqlDataProvider")]
[InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8DataAccessMode")]
[InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8RuleCreationPolicy")]
[InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.S8RuleCreationOrigin")]
// S8-RULE-GOVERNANCE-BATCH1:判定语义迁入代码定义后,三个 params 解析类一并删除。
[InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8TimeoutParams")]
[InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8ShortageParams")]
[InlineData("Admin.NET.Plugin.AiDOP.Service.S8.Rules.S8OutOfRangeParams")]
public void LegacyDataAccessType_NoLongerExists(string fullName)
{
Assert.Null(PluginAssembly.GetType(fullName, throwOnError: false));
}
///
/// 取代数据源端点的是一个**只读**目录。二者性质不同,不是改名:
/// 旧的是租户可写的物理连接配置,新的是平台级只读目录。
///
[Fact]
public void DatasetsController_IsReadOnly()
{
var actions = typeof(AdoS8ConfigDatasetsController)
.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.ToArray();
Assert.NotEmpty(actions);
foreach (var m in actions)
{
Assert.Empty(m.GetCustomAttributes());
Assert.Empty(m.GetCustomAttributes());
Assert.Empty(m.GetCustomAttributes());
Assert.Empty(m.GetCustomAttributes());
}
}
/// Rule 01 的数据集仍在目录内(供给出来的行必须能通过 Enable Gate 的目录检查)。
[Fact]
public void Rule01Dataset_RemainsDefinedInCatalog()
{
var catalog = new S8DatasetCatalog(new IS8DatasetDefinitionSource[]
{
new Admin.NET.Plugin.AiDOP.Service.S8.Rules.DataAccess.Providers.S8BusinessDatasetDefinitions()
});
Assert.True(catalog.IsDefined("PURCHASE_DELIVERY"));
}
// ───────────────────────── 保留能力不得回归 ─────────────────────────
///
/// T9–T11:退役不得误伤仍然合法的能力。
/// 少一个都意味着用户连"调整已发布规则的运行策略"都做不到了。
///
[Theory]
[InlineData("UpdateParametersAsync")]
[InlineData("EnableAsync")]
[InlineData("DisableAsync")]
[InlineData("UpdateScheduleAsync")]
[InlineData("RunNowAsync")]
[InlineData("PauseAsync")]
[InlineData("ResumeAsync")]
[InlineData("ListAsync")]
public void SurvivingCapabilities_AreIntact(string method)
{
Assert.NotEmpty(typeof(S8WatchRuleService).GetMethods().Where(m => m.Name == method));
}
[Theory]
[InlineData("PreviewAsync", "GET")]
[InlineData("ProvisionAsync", "POST")]
[InlineData("EnableAsync", "POST")]
[InlineData("DisableAsync", "POST")]
[InlineData("UpdateParamsAsync", "PUT")]
public void SurvivingEndpoints_AreIntact(string action, string verb)
{
var m = Assert.Single(Actions(typeof(AdoS8ConfigWatchRulesController), action));
var has = verb switch
{
"GET" => m.GetCustomAttributes().Any(),
"POST" => m.GetCustomAttributes().Any(),
"PUT" => m.GetCustomAttributes().Any(),
_ => false
};
Assert.True(has, $"{action} 必须仍是 {verb}");
}
private static IEnumerable Actions(Type controller, string name) =>
controller.GetMethods(BindingFlags.Public | BindingFlags.Instance | BindingFlags.DeclaredOnly)
.Where(m => m.Name == name);
private static string PluginSourceRoot()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP")))
dir = dir.Parent;
Assert.NotNull(dir);
return Path.Combine(dir!.FullName, "Admin.NET.Plugin.AiDOP");
}
/// 只取可执行代码行:注释里为留档会复述旧写法,不应算违规。
private static string ExecutableLines(string code) =>
string.Join('\n', code.Split('\n')
.Where(l =>
{
var t = l.TrimStart();
return !t.StartsWith("///", StringComparison.Ordinal)
&& !t.StartsWith("//", StringComparison.Ordinal)
&& !t.StartsWith("*", StringComparison.Ordinal);
}));
}