using System.Text.RegularExpressions;
using Admin.NET.Plugin.AiDOP.Dto.S0.Manufacturing;
using Admin.NET.Plugin.AiDOP.Entity.S0.Manufacturing;
using SqlSugar;
using Xunit;
namespace Admin.NET.Plugin.AiDOP.Tests.S0.Tenant;
///
/// S0-WORKORD-CONTROL-TENANT-UX-FIX 防回归契约。
///
/// 守住四条:
/// 1. Upsert 按 Id 定位必须先 tenant scope,禁止裸 GetByIdAsync(P0 越租户更新漏洞);
/// 2. Id 不属于当前租户时必须 NotFound,不得落到 Update 分支;
/// 3. GET / Upsert 的 Domain 均为可选,页面加载不依赖用户手输工厂域;
/// 4. 唯一索引必须以 TenantId 打头,否则 CodeFirst 重建后会退化为跨租户唯一。
///
/// 与同目录其他契约测试一致:用源码静态扫描,不需要真实 DB / HTTP 上下文。
/// 真实的双租户读写隔离由集成冒烟(两个租户 token 实调)覆盖。
///
public class S0WorkOrdControlTenantTests
{
private static string ControllerSource()
{
var dir = new DirectoryInfo(AppContext.BaseDirectory);
while (dir != null && !Directory.Exists(Path.Combine(dir.FullName, "Admin.NET.Plugin.AiDOP", "Controllers", "S0")))
dir = dir.Parent;
Assert.NotNull(dir);
var path = Path.Combine(dir!.FullName, "Admin.NET.Plugin.AiDOP", "Controllers", "S0",
"Manufacturing", "AdoS0WorkOrdControlController.cs");
Assert.True(File.Exists(path), $"未找到 {path}");
return File.ReadAllText(path);
}
/// 去掉注释行,避免注释里的说明文字造成误判。
private static string StripComments(string src)
{
var withoutBlock = Regex.Replace(src, @"/\*.*?\*/", string.Empty, RegexOptions.Singleline);
var lines = withoutBlock.Split('\n').Where(l => !l.TrimStart().StartsWith("//", StringComparison.Ordinal));
return string.Join('\n', lines);
}
/// P0:不得使用未加租户条件的按 Id 查询。
[Fact]
public void Upsert_MustNotUseUnscopedGetById()
{
var src = StripComments(ControllerSource());
Assert.DoesNotContain("GetByIdAsync", src);
Assert.DoesNotContain("GetSingleAsync", src);
// 按 Id 定位必须建立在 ScopedTo(tenantId) 之上。
Assert.Matches(new Regex(@"ScopedTo\(tenantId\)\s*\r?\n?\s*\.Where\(x => x\.Id == dto\.Id"), src);
}
/// P0:Id 命中失败必须 404,绝不继续走到 Update。
[Fact]
public void Upsert_MustReturnNotFoundWhenIdBelongsToAnotherTenant()
{
var src = StripComments(ControllerSource());
Assert.Contains("return NotFound();", src);
var scopedIdIdx = src.IndexOf("x.Id == dto.Id", StringComparison.Ordinal);
var notFoundIdx = src.IndexOf("return NotFound();", StringComparison.Ordinal);
Assert.True(scopedIdIdx >= 0 && notFoundIdx > scopedIdIdx,
"按 Id 的租户内查询之后必须紧跟 NotFound 分支。");
var updateIdx = src.IndexOf("AsUpdateable", StringComparison.Ordinal);
Assert.True(updateIdx > notFoundIdx, "NotFound 必须早于任何 Update 调用。");
}
/// 每个 action 仍必须显式解析租户。
[Fact]
public void AllActions_ResolveTenantFirst()
{
var src = StripComments(ControllerSource());
var actions = Regex.Matches(src, @"public async Task (\w+)\(").Count;
var resolves = Regex.Matches(src, @"AdoS0TenantScope\.TryResolveRequired").Count;
Assert.True(actions > 0);
Assert.Equal(actions, resolves);
}
/// Domain 必须可选:GET 不传也能取到当前租户配置。
[Fact]
public void DomainMustBeOptional_OnBothDtos()
{
var get = typeof(AdoS0WorkOrdControlGetDto).GetProperty(nameof(AdoS0WorkOrdControlGetDto.Domain))!;
var upsert = typeof(AdoS0WorkOrdControlUpsertDto).GetProperty(nameof(AdoS0WorkOrdControlUpsertDto.Domain))!;
Assert.Equal(typeof(string), get.PropertyType);
Assert.Equal(typeof(string), upsert.PropertyType);
Assert.Empty(get.GetCustomAttributes(typeof(System.ComponentModel.DataAnnotations.RequiredAttribute), true));
Assert.Empty(upsert.GetCustomAttributes(typeof(System.ComponentModel.DataAnnotations.RequiredAttribute), true));
// 查询里 Domain 只能作为可选筛选(WhereIF),不得是硬条件。
var src = StripComments(ControllerSource());
Assert.Contains("WhereIF(!string.IsNullOrWhiteSpace(domain)", src);
}
/// 唯一索引必须以 TenantId 打头,否则跨租户抢占同一 Domain。
[Fact]
public void UniqueIndexMustLeadWithTenantId()
{
var index = typeof(AdoS0WorkOrdControl)
.GetCustomAttributes(typeof(SugarIndexAttribute), inherit: true)
.OfType()
.Single();
Assert.Equal(nameof(AdoS0WorkOrdControl.TenantId), index.IndexFields.Keys.First());
Assert.Contains(nameof(AdoS0WorkOrdControl.Domain), index.IndexFields.Keys);
Assert.True(index.IsUnique);
}
}