Files
MoShiro/backend/tests/Monitor.Backend.Tests/ArchitectureTests.cs
T

230 lines
13 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
}
}