230 lines
13 KiB
C#
230 lines
13 KiB
C#
using System.Xml.Linq;
|
||
|
||
namespace Monitor.Backend.Tests;
|
||
|
||
/// <summary>
|
||
/// 依赖规则(docs/project-layout.md 第 3 节):由测试在构建期强制,等价于文档里的 <c>eng/check-deps</c>。
|
||
/// 读取仓库里所有 .csproj 和源文件,违反规则就失败。
|
||
/// </summary>
|
||
public sealed class ArchitectureTests
|
||
{
|
||
private static readonly string s_root = FindRoot();
|
||
|
||
private static readonly string[] s_allowedAnalyzers = ["Microsoft.CodeAnalysis.BannedApiAnalyzers"];
|
||
|
||
private sealed record Project(string Name, string Path, string Directory, IReadOnlyList<string> ProjectReferences, IReadOnlyList<string> Packages);
|
||
|
||
private static string FindRoot()
|
||
{
|
||
string? dir = AppContext.BaseDirectory;
|
||
while (dir is not null && !File.Exists(Path.Combine(dir, "global.json")))
|
||
{
|
||
dir = Path.GetDirectoryName(dir);
|
||
}
|
||
|
||
return dir ?? throw new InvalidOperationException("repository root not found");
|
||
}
|
||
|
||
private static List<Project> LoadProjects() =>
|
||
[.. Directory.EnumerateFiles(s_root, "*.csproj", SearchOption.AllDirectories)
|
||
.Where(p => !p.Contains(Path.DirectorySeparatorChar + "artifacts" + Path.DirectorySeparatorChar, StringComparison.Ordinal) && !p.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal))
|
||
.Select(path =>
|
||
{
|
||
XDocument doc = XDocument.Load(path);
|
||
IEnumerable<XElement> items = doc.Descendants("ItemGroup").Elements();
|
||
return new Project(
|
||
Path.GetFileNameWithoutExtension(path),
|
||
path,
|
||
Path.GetDirectoryName(path)!,
|
||
[.. items.Where(e => e.Name == "ProjectReference").Select(e => Path.GetFileNameWithoutExtension(NormalizePath((string?)e.Attribute("Include") ?? "")))],
|
||
[.. items.Where(e => e.Name == "PackageReference").Select(e => (string?)e.Attribute("Include") ?? "")]);
|
||
})];
|
||
|
||
private static string NormalizePath(string path) =>
|
||
path.Replace('\\', Path.DirectorySeparatorChar).Replace('/', Path.DirectorySeparatorChar);
|
||
|
||
private static bool IsTest(Project p) => p.Path.Contains(Path.DirectorySeparatorChar + "tests" + Path.DirectorySeparatorChar, StringComparison.Ordinal);
|
||
|
||
private static bool IsFleet(Project p) => p.Name.StartsWith("Fleet.", StringComparison.Ordinal) && !IsTest(p);
|
||
|
||
[Fact]
|
||
public void Fleet_NeverReferencesMonitor_InProjectsOrCode()
|
||
{
|
||
foreach (Project project in LoadProjects().Where(IsFleet))
|
||
{
|
||
Assert.DoesNotContain(project.ProjectReferences, r => r.StartsWith("Monitor.", StringComparison.Ordinal));
|
||
foreach (string file in Directory.EnumerateFiles(project.Directory, "*.cs", SearchOption.AllDirectories).Where(f => !f.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal)))
|
||
{
|
||
string text = File.ReadAllText(file);
|
||
Assert.False(text.Contains("using Monitor.", StringComparison.Ordinal) || text.Contains("namespace Monitor.", StringComparison.Ordinal), $"{file} refers to Monitor.*");
|
||
Assert.False(text.Contains("monitor.", StringComparison.Ordinal) && text.Contains("\"monitor.", StringComparison.Ordinal), $"{file} hard-codes a Monitor method or channel name");
|
||
Assert.False(text.Contains("7443", StringComparison.Ordinal), $"{file} hard-codes a Monitor port");
|
||
}
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void NodeSideLibraries_HaveNoRuntimePackages_AndUseTheNodeBanList()
|
||
{
|
||
string[] nodeSide = ["Fleet.Core", "Fleet.Identity", "Fleet.Rpc", "Monitor.Protocol", "Monitor.Backend.Analysis"];
|
||
foreach (Project project in LoadProjects().Where(p => nodeSide.Contains(p.Name)))
|
||
{
|
||
Assert.All(project.Packages, package => Assert.Contains(package, s_allowedAnalyzers));
|
||
}
|
||
|
||
foreach (string name in new[] { "Fleet.Core", "Fleet.Identity", "Fleet.Rpc" })
|
||
{
|
||
Project project = LoadProjects().Single(p => p.Name == name);
|
||
Assert.Contains("BannedSymbols.Node.txt", File.ReadAllText(project.Path), StringComparison.Ordinal);
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void Protocol_DependsOnlyOnFleetCoreAndRpc_AndAnalysisOnlyOnProtocol()
|
||
{
|
||
List<Project> projects = LoadProjects();
|
||
Assert.Equal(["Fleet.Core", "Fleet.Rpc"], projects.Single(p => p.Name == "Monitor.Protocol").ProjectReferences.Order().ToArray());
|
||
Assert.Equal(["Monitor.Protocol"], projects.Single(p => p.Name == "Monitor.Backend.Analysis").ProjectReferences.ToArray());
|
||
}
|
||
|
||
[Fact]
|
||
public void NodeProcesses_AreCollectorsOnly_NoBackendNoServerLibrariesNoRuntimePackages()
|
||
{
|
||
List<Project> projects = LoadProjects();
|
||
Project Get(string name) => projects.Single(p => p.Name == name);
|
||
|
||
// Agent 是纯采集进程:不引用后端的任何层,也不引用 Fleet 的服务端库
|
||
Project agent = Get("Monitor.Agent");
|
||
Assert.DoesNotContain(agent.ProjectReferences, r => r.StartsWith("Monitor.Backend", StringComparison.Ordinal) || r.StartsWith("Fleet.Server", StringComparison.Ordinal) || r.StartsWith("Fleet.Enrollment", StringComparison.Ordinal));
|
||
Assert.Equal(["Fleet.Hosting", "Fleet.Link", "Monitor.Measurement"], agent.ProjectReferences.Order().ToArray());
|
||
|
||
// Probe 同理,并且不认识 Backend 和 Agent
|
||
Project probe = Get("Monitor.Probe");
|
||
Assert.Equal(["Fleet.Hosting", "Fleet.Link", "Monitor.Measurement"], probe.ProjectReferences.Order().ToArray());
|
||
|
||
// 两个节点的共享库只依赖协议
|
||
Assert.Equal(["Monitor.Protocol"], Get("Monitor.Measurement").ProjectReferences.ToArray());
|
||
|
||
// 节点一侧的库没有运行时依赖包,并使用节点禁用列表(不能用 HttpClient、Process、写文件;唯一的写文件豁免在 ConfigStore)
|
||
foreach (string name in new[] { "Fleet.Link", "Fleet.Hosting", "Monitor.Measurement", "Monitor.Agent", "Monitor.Probe" })
|
||
{
|
||
Project project = Get(name);
|
||
Assert.All(project.Packages, package => Assert.Contains(package, s_allowedAnalyzers));
|
||
string settings = File.ReadAllText(project.Path);
|
||
for (string? dir = project.Directory; dir is not null && dir.StartsWith(s_root, StringComparison.Ordinal); dir = Path.GetDirectoryName(dir))
|
||
{
|
||
string props = Path.Combine(dir, "Directory.Build.props");
|
||
settings += File.Exists(props) ? File.ReadAllText(props) : "";
|
||
}
|
||
|
||
Assert.Contains("BannedSymbols.Node.txt", settings, StringComparison.Ordinal);
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void CollectionLayer_KnowsNoBusinessProtocolsOrPlans_OnlyMechanismsAndRawColumns()
|
||
{
|
||
// 采集层(协议、积木、Agent、Probe、Fleet)只认识机制(传输、节奏、方向、时间戳)和原始列;
|
||
// REALITY、Hysteria2、Minecraft 这些业务概念以及由原语编排成的测试方案只存在于 Backend。
|
||
string[] collectionRoots = ["shared/Monitor.Protocol", "shared/Monitor.Measurement", "agent/src", "probe/src", "fleet/src"];
|
||
string[] forbidden = ["reality", "hysteria", "hy2", "minecraft", "brutal", "PlanSpec", "PhaseSpec"];
|
||
foreach (string root in collectionRoots)
|
||
{
|
||
string directory = Path.Combine(s_root, root.Replace('/', Path.DirectorySeparatorChar));
|
||
foreach (string file in Directory.EnumerateFiles(directory, "*.cs", SearchOption.AllDirectories).Where(f => !f.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal)))
|
||
{
|
||
string text = File.ReadAllText(file);
|
||
foreach (string word in forbidden)
|
||
{
|
||
Assert.False(text.Contains(word, StringComparison.OrdinalIgnoreCase), $"{file} mentions '{word}': business concepts belong to the backend only");
|
||
}
|
||
}
|
||
}
|
||
|
||
// 派生量(丢包率、RTT、抖动)由 Backend 的分析层算:采集层的积木不引用分析层
|
||
Assert.DoesNotContain(LoadProjects().Single(p => p.Name == "Monitor.Measurement").ProjectReferences, r => r.Contains("Analysis", StringComparison.Ordinal));
|
||
}
|
||
|
||
[Fact]
|
||
public void BackendLayers_ObeyTheDependencyRules()
|
||
{
|
||
List<Project> projects = LoadProjects();
|
||
Project Get(string name) => projects.Single(p => p.Name == name);
|
||
|
||
// Api 不能引用 Storage(也不引用 Gateway):只调用 Application
|
||
Assert.DoesNotContain(Get("Monitor.Backend.Api").ProjectReferences, r => r.Contains("Storage", StringComparison.Ordinal) || r.Contains("Gateway", StringComparison.Ordinal));
|
||
Assert.Equal(["Monitor.Backend.Application"], Get("Monitor.Backend.Api").ProjectReferences.ToArray());
|
||
|
||
// Application 不依赖任何下游层
|
||
Assert.DoesNotContain(Get("Monitor.Backend.Application").ProjectReferences, r => r.Contains("Storage", StringComparison.Ordinal) || r.Contains("Api", StringComparison.Ordinal) || r.Contains("Gateway", StringComparison.Ordinal));
|
||
|
||
// Gateway 只通过 Application 与 Fleet.Server 工作
|
||
Assert.DoesNotContain(Get("Monitor.Backend.Gateway").ProjectReferences, r => r.Contains("Storage", StringComparison.Ordinal) || r.Contains("Backend.Api", StringComparison.Ordinal));
|
||
|
||
// Storage 实现 Application 的端口,不认识 Api 和 Gateway
|
||
Assert.DoesNotContain(Get("Monitor.Backend.Storage.Sqlite").ProjectReferences, r => r.Contains("Backend.Api", StringComparison.Ordinal) || r.Contains("Gateway", StringComparison.Ordinal));
|
||
|
||
// 只有宿主(组合根)认识所有层
|
||
Assert.Equivalent(
|
||
new[] { "Monitor.Backend.Api", "Monitor.Backend.Gateway", "Monitor.Backend.Storage.Sqlite" },
|
||
Get("Monitor.Backend").ProjectReferences.ToArray());
|
||
}
|
||
|
||
[Fact]
|
||
public void BackendPackages_AreLimitedToTheDocumentedSet()
|
||
{
|
||
string[] allowed =
|
||
[
|
||
"Microsoft.CodeAnalysis.BannedApiAnalyzers", "Microsoft.Data.Sqlite", "Microsoft.AspNetCore.OpenApi",
|
||
"Microsoft.Extensions.ApiDescription.Server", "xunit.v3", "Microsoft.Extensions.TimeProvider.Testing",
|
||
];
|
||
foreach (Project project in LoadProjects())
|
||
{
|
||
Assert.All(project.Packages, package => Assert.Contains(package, allowed));
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void ApplicationAndApi_NeverTouchSqliteOrTheFileSystemDirectly()
|
||
{
|
||
foreach (string layer in new[] { "Monitor.Backend.Application", "Monitor.Backend.Api", "Monitor.Backend.Gateway", "Monitor.Backend.Analysis" })
|
||
{
|
||
string dir = Path.Combine(s_root, "src", layer);
|
||
foreach (string file in Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories).Where(f => !f.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal)))
|
||
{
|
||
string text = File.ReadAllText(file);
|
||
Assert.DoesNotContain("Microsoft.Data.Sqlite", text, StringComparison.Ordinal);
|
||
Assert.DoesNotContain("File.WriteAll", text, StringComparison.Ordinal);
|
||
Assert.DoesNotContain("new FileStream", text, StringComparison.Ordinal);
|
||
}
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void NoProductionCode_BuildsSqlByConcatenatingValues()
|
||
{
|
||
// 手写的参数化 SQL:只有表名可以来自常量;值一律通过参数
|
||
string dir = Path.Combine(s_root, "src", "Monitor.Backend.Storage.Sqlite");
|
||
foreach (string file in Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories).Where(f => !f.Contains(Path.DirectorySeparatorChar + "obj" + Path.DirectorySeparatorChar, StringComparison.Ordinal)))
|
||
{
|
||
string text = File.ReadAllText(file);
|
||
Assert.DoesNotContain("+ id +", text, StringComparison.Ordinal);
|
||
Assert.DoesNotContain("+ name +", text, StringComparison.Ordinal);
|
||
Assert.DoesNotContain("+ agent", text, StringComparison.Ordinal);
|
||
Assert.DoesNotContain("'\" +", text, StringComparison.Ordinal);
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void EveryProject_ImportsTheSharedBuildSettings()
|
||
{
|
||
// backend/ 下的 Directory.Build.props 对所有项目生效:严格的警告、BannedSymbols、.editorconfig 的风格规则
|
||
string props = File.ReadAllText(Path.Combine(s_root, "Directory.Build.props"));
|
||
Assert.Contains("<TreatWarningsAsErrors>true</TreatWarningsAsErrors>", props, StringComparison.Ordinal);
|
||
Assert.Contains("<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>", props, StringComparison.Ordinal);
|
||
Assert.Contains("BannedSymbols.txt", props, StringComparison.Ordinal);
|
||
Assert.Contains("DateTime.UtcNow", File.ReadAllText(Path.Combine(s_root, "BannedSymbols.txt")), StringComparison.Ordinal);
|
||
Assert.Contains("System.Diagnostics.Process", File.ReadAllText(Path.Combine(s_root, "BannedSymbols.Node.txt")), StringComparison.Ordinal);
|
||
}
|
||
}
|