Replace Probe with Agent across node runtime, Fleet contracts, backend state, API and storage. Report incoming sessions and outgoing link health through each Agent uplink, and retain directed link controls and retry. Update protocol fixtures and end-to-end coverage for the unified model; accept requested cancellation during the Fleet timeout test cleanup.
228 lines
13 KiB
C#
228 lines
13 KiB
C#
using System.Xml.Linq;
|
||
|
||
namespace MoShiro.Backend.Tests;
|
||
|
||
/// <summary>
|
||
/// 依赖规则(docs/dev/project-layout.md 第 3 节):由测试在构建期强制。
|
||
/// 读取仓库里所有 .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;
|
||
// backend/ 目录的标志:BannedSymbols.txt 只在这里(global.json 和 NuGet 版本表已经挪到仓库根目录)
|
||
while (dir is not null && !File.Exists(Path.Combine(dir, "BannedSymbols.txt")))
|
||
{
|
||
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_NeverReferencesMoShiro_InProjectsOrCode()
|
||
{
|
||
foreach (Project project in LoadProjects().Where(IsFleet))
|
||
{
|
||
Assert.DoesNotContain(project.ProjectReferences, r => r.StartsWith("MoShiro.", 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 MoShiro.", StringComparison.Ordinal) || text.Contains("namespace MoShiro.", StringComparison.Ordinal), $"{file} refers to MoShiro.*");
|
||
Assert.False(text.Contains("moshiro.", StringComparison.Ordinal) && text.Contains("\"moshiro.", StringComparison.Ordinal), $"{file} hard-codes a MoShiro method or channel name");
|
||
Assert.False(text.Contains("7443", StringComparison.Ordinal), $"{file} hard-codes a MoShiro port");
|
||
}
|
||
}
|
||
}
|
||
|
||
[Fact]
|
||
public void NodeSideLibraries_HaveNoRuntimePackages_AndUseTheNodeBanList()
|
||
{
|
||
string[] nodeSide = ["Fleet.Core", "Fleet.Identity", "Fleet.Rpc", "MoShiro.Protocol", "MoShiro.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 == "MoShiro.Protocol").ProjectReferences.Order().ToArray());
|
||
Assert.Equal(["MoShiro.Protocol"], projects.Single(p => p.Name == "MoShiro.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("MoShiro.Agent");
|
||
Assert.DoesNotContain(agent.ProjectReferences, r => r.StartsWith("MoShiro.Backend", StringComparison.Ordinal) || r.StartsWith("Fleet.Server", StringComparison.Ordinal) || r.StartsWith("Fleet.Enrollment", StringComparison.Ordinal));
|
||
Assert.Equal(["Fleet.Hosting", "Fleet.Link", "MoShiro.Measurement"], agent.ProjectReferences.Order().ToArray());
|
||
|
||
// 两个节点的共享库只依赖协议
|
||
// 测量库在 TCP 数据面上做预握手敲门,所以直接引用 Fleet.Identity(节点侧库)
|
||
Assert.Equal(["Fleet.Identity", "MoShiro.Protocol"], Get("MoShiro.Measurement").ProjectReferences.Order().ToArray());
|
||
|
||
// 节点一侧的库没有运行时依赖包,并使用节点禁用列表(不能用 HttpClient、Process、写文件;唯一的写文件豁免在 ConfigStore)
|
||
foreach (string name in new[] { "Fleet.Link", "Fleet.Hosting", "MoShiro.Measurement", "MoShiro.Agent" })
|
||
{
|
||
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、Fleet)只认识机制(传输、节奏、方向、时间戳)和原始列;
|
||
// REALITY、Hysteria2、Minecraft 这些业务概念以及由原语编排成的测试方案只存在于 Backend。
|
||
string[] collectionRoots = ["shared/MoShiro.Protocol", "shared/MoShiro.Measurement", "agent/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 == "MoShiro.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("MoShiro.Backend.Api").ProjectReferences, r => r.Contains("Storage", StringComparison.Ordinal) || r.Contains("Gateway", StringComparison.Ordinal));
|
||
Assert.Equal(["MoShiro.Backend.Application"], Get("MoShiro.Backend.Api").ProjectReferences.ToArray());
|
||
|
||
// Application 不依赖任何下游层
|
||
Assert.DoesNotContain(Get("MoShiro.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("MoShiro.Backend.Gateway").ProjectReferences, r => r.Contains("Storage", StringComparison.Ordinal) || r.Contains("Backend.Api", StringComparison.Ordinal));
|
||
|
||
// Storage 实现 Application 的端口,不认识 Api 和 Gateway
|
||
Assert.DoesNotContain(Get("MoShiro.Backend.Storage.Sqlite").ProjectReferences, r => r.Contains("Backend.Api", StringComparison.Ordinal) || r.Contains("Gateway", StringComparison.Ordinal));
|
||
|
||
// 只有宿主(组合根)认识所有层
|
||
Assert.Equivalent(
|
||
new[] { "MoShiro.Backend.Api", "MoShiro.Backend.Gateway", "MoShiro.Backend.Storage.Sqlite" },
|
||
Get("MoShiro.Backend").ProjectReferences.ToArray());
|
||
}
|
||
|
||
[Fact]
|
||
public void BackendPackages_AreLimitedToTheDocumentedSet()
|
||
{
|
||
string[] allowed =
|
||
[
|
||
"Microsoft.CodeAnalysis.BannedApiAnalyzers", "Microsoft.Data.Sqlite", "SQLitePCLRaw.lib.e_sqlite3", "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[] { "MoShiro.Backend.Application", "MoShiro.Backend.Api", "MoShiro.Backend.Gateway", "MoShiro.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", "MoShiro.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);
|
||
}
|
||
}
|