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); } }