268 lines
10 KiB
C#
268 lines
10 KiB
C#
using System.Reflection;
|
|
using System.Text.RegularExpressions;
|
|
using System.Xml.Linq;
|
|
using ZB.MOM.WW.CBDDC.Core;
|
|
|
|
namespace ZB.MOM.WW.CBDDC.Core.Tests;
|
|
|
|
public class ArchitectureFitnessTests
|
|
{
|
|
/// <summary>
|
|
/// Verifies that the core assembly does not reference outer-layer assemblies.
|
|
/// </summary>
|
|
[Fact]
|
|
public void CoreAssembly_ShouldNotReferenceOuterAssemblies()
|
|
{
|
|
var references = typeof(OplogEntry).Assembly
|
|
.GetReferencedAssemblies()
|
|
.Select(a => a.Name)
|
|
.Where(a => !string.IsNullOrWhiteSpace(a))
|
|
.ToHashSet(StringComparer.Ordinal);
|
|
|
|
references.ShouldNotContain("ZB.MOM.WW.CBDDC.Network");
|
|
references.ShouldNotContain("ZB.MOM.WW.CBDDC.Persistence");
|
|
references.ShouldNotContain("ZB.MOM.WW.CBDDC.Hosting");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies that project references under src form an acyclic graph.
|
|
/// </summary>
|
|
[Fact]
|
|
public void SourceProjectGraph_ShouldBeAcyclic()
|
|
{
|
|
var repoRoot = FindRepoRoot();
|
|
var srcRoot = Path.Combine(repoRoot, "src");
|
|
|
|
var projectFiles = Directory
|
|
.EnumerateFiles(srcRoot, "*.csproj", SearchOption.AllDirectories)
|
|
.Where(p => !p.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal)
|
|
&& !p.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal))
|
|
.ToList();
|
|
|
|
var nodes = projectFiles.ToDictionary(
|
|
p => Path.GetFileNameWithoutExtension(p),
|
|
p => new HashSet<string>(StringComparer.Ordinal));
|
|
|
|
foreach (var projectFile in projectFiles)
|
|
{
|
|
var projectName = Path.GetFileNameWithoutExtension(projectFile);
|
|
var doc = XDocument.Load(projectFile);
|
|
var refs = doc.Descendants("ProjectReference")
|
|
.Select(x => x.Attribute("Include")?.Value)
|
|
.Where(v => !string.IsNullOrWhiteSpace(v))
|
|
.Select(v => Path.GetFileNameWithoutExtension(v!.Replace('\\', '/')));
|
|
|
|
foreach (var reference in refs)
|
|
{
|
|
if (nodes.ContainsKey(reference))
|
|
{
|
|
nodes[projectName].Add(reference);
|
|
}
|
|
}
|
|
}
|
|
|
|
HasCycle(nodes).ShouldBeFalse();
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies the allowed dependency graph between source projects.
|
|
/// </summary>
|
|
[Fact]
|
|
public void SourceProjectReferences_ShouldMatchAllowedDependencyGraph()
|
|
{
|
|
var repoRoot = FindRepoRoot();
|
|
var srcRoot = Path.Combine(repoRoot, "src");
|
|
|
|
var projectFiles = Directory
|
|
.EnumerateFiles(srcRoot, "*.csproj", SearchOption.AllDirectories)
|
|
.Where(p => !p.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal)
|
|
&& !p.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal))
|
|
.ToList();
|
|
|
|
var allowedDependencies = new Dictionary<string, HashSet<string>>(StringComparer.Ordinal)
|
|
{
|
|
["ZB.MOM.WW.CBDDC.Core"] = new HashSet<string>(StringComparer.Ordinal),
|
|
["ZB.MOM.WW.CBDDC.Network"] = new HashSet<string>(StringComparer.Ordinal) { "ZB.MOM.WW.CBDDC.Core" },
|
|
["ZB.MOM.WW.CBDDC.Persistence"] = new HashSet<string>(StringComparer.Ordinal) { "ZB.MOM.WW.CBDDC.Core" },
|
|
["ZB.MOM.WW.CBDDC.Hosting"] = new HashSet<string>(StringComparer.Ordinal) { "ZB.MOM.WW.CBDDC.Network" }
|
|
};
|
|
|
|
foreach (var projectFile in projectFiles)
|
|
{
|
|
var projectName = Path.GetFileNameWithoutExtension(projectFile);
|
|
allowedDependencies.ContainsKey(projectName)
|
|
.ShouldBeTrue($"Unexpected source project found: {projectName}");
|
|
|
|
var doc = XDocument.Load(projectFile);
|
|
var references = doc.Descendants("ProjectReference")
|
|
.Select(x => x.Attribute("Include")?.Value)
|
|
.Where(v => !string.IsNullOrWhiteSpace(v))
|
|
.Select(v => Path.GetFileNameWithoutExtension(v!.Replace('\\', '/')))
|
|
.ToHashSet(StringComparer.Ordinal);
|
|
|
|
var expected = allowedDependencies[projectName];
|
|
var extra = references.Where(r => !expected.Contains(r)).ToList();
|
|
var missing = expected.Where(e => !references.Contains(e)).ToList();
|
|
|
|
extra.ShouldBeEmpty($"Project {projectName} has disallowed references: {string.Join(", ", extra)}");
|
|
missing.ShouldBeEmpty($"Project {projectName} is missing required references: {string.Join(", ", missing)}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies non-generic ILogger usage is restricted to explicit compatibility shims.
|
|
/// </summary>
|
|
[Fact]
|
|
public void SourceCode_ShouldRestrictNonGenericILoggerUsage()
|
|
{
|
|
var repoRoot = FindRepoRoot();
|
|
var srcRoot = Path.Combine(repoRoot, "src");
|
|
var loggerPattern = new Regex(@"\bILogger\b(?!\s*<|\s*Factory\b)", RegexOptions.Compiled);
|
|
|
|
var allowedSnippets = new[]
|
|
{
|
|
"private readonly ILogger _inner;",
|
|
"internal ProtocolHandler(ILogger logger",
|
|
"ILogger? logger = null)",
|
|
"CreateTypedLogger(ILogger? logger)",
|
|
"public ForwardingLogger(ILogger inner)"
|
|
};
|
|
|
|
var violations = new List<string>();
|
|
var sourceFiles = Directory.EnumerateFiles(srcRoot, "*.cs", SearchOption.AllDirectories)
|
|
.Where(p => !p.Contains($"{Path.DirectorySeparatorChar}obj{Path.DirectorySeparatorChar}", StringComparison.Ordinal)
|
|
&& !p.Contains($"{Path.DirectorySeparatorChar}bin{Path.DirectorySeparatorChar}", StringComparison.Ordinal));
|
|
|
|
foreach (var file in sourceFiles)
|
|
{
|
|
var lines = File.ReadAllLines(file);
|
|
for (var i = 0; i < lines.Length; i++)
|
|
{
|
|
var line = lines[i].Trim();
|
|
if (string.IsNullOrWhiteSpace(line) || line.StartsWith("//", StringComparison.Ordinal))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (!loggerPattern.IsMatch(line))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
if (allowedSnippets.Any(line.Contains))
|
|
{
|
|
continue;
|
|
}
|
|
|
|
var relativePath = Path.GetRelativePath(repoRoot, file).Replace('\\', '/');
|
|
violations.Add($"{relativePath}:{i + 1} -> {line}");
|
|
}
|
|
}
|
|
|
|
violations.ShouldBeEmpty($"Unexpected non-generic ILogger usage:{Environment.NewLine}{string.Join(Environment.NewLine, violations)}");
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies log boundaries push operation context for hosted/background entry points.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BoundaryServices_ShouldPushOperationLogContext()
|
|
{
|
|
var repoRoot = FindRepoRoot();
|
|
var boundaryFiles = new[]
|
|
{
|
|
"src/ZB.MOM.WW.CBDDC.Network/CBDDCNodeService.cs",
|
|
"src/ZB.MOM.WW.CBDDC.Network/SyncOrchestrator.cs",
|
|
"src/ZB.MOM.WW.CBDDC.Network/TcpSyncServer.cs",
|
|
"src/ZB.MOM.WW.CBDDC.Hosting/HostedServices/DiscoveryServiceHostedService.cs",
|
|
"src/ZB.MOM.WW.CBDDC.Hosting/HostedServices/TcpSyncServerHostedService.cs",
|
|
"src/ZB.MOM.WW.CBDDC.Hosting/Services/NoOpDiscoveryService.cs",
|
|
"src/ZB.MOM.WW.CBDDC.Hosting/Services/NoOpSyncOrchestrator.cs"
|
|
};
|
|
|
|
foreach (var relativePath in boundaryFiles)
|
|
{
|
|
var filePath = Path.Combine(repoRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
|
|
File.Exists(filePath).ShouldBeTrue($"Missing expected boundary file: {relativePath}");
|
|
|
|
var contents = File.ReadAllText(filePath);
|
|
contents.Contains("LogContext.PushProperty(\"OperationId\"", StringComparison.Ordinal)
|
|
.ShouldBeTrue($"Boundary file is missing OperationId log enrichment: {relativePath}");
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Verifies boundary projects include Serilog for LogContext support.
|
|
/// </summary>
|
|
[Fact]
|
|
public void BoundaryProjects_ShouldReferenceSerilog()
|
|
{
|
|
var repoRoot = FindRepoRoot();
|
|
var projects = new[]
|
|
{
|
|
"src/ZB.MOM.WW.CBDDC.Network/ZB.MOM.WW.CBDDC.Network.csproj",
|
|
"src/ZB.MOM.WW.CBDDC.Hosting/ZB.MOM.WW.CBDDC.Hosting.csproj",
|
|
"samples/ZB.MOM.WW.CBDDC.Sample.Console/ZB.MOM.WW.CBDDC.Sample.Console.csproj"
|
|
};
|
|
|
|
foreach (var relativePath in projects)
|
|
{
|
|
var filePath = Path.Combine(repoRoot, relativePath.Replace('/', Path.DirectorySeparatorChar));
|
|
File.Exists(filePath).ShouldBeTrue($"Missing project file: {relativePath}");
|
|
|
|
var contents = File.ReadAllText(filePath);
|
|
contents.Contains("<PackageReference Include=\"Serilog\"", StringComparison.Ordinal)
|
|
.ShouldBeTrue($"Serilog package reference is required for logging boundary enrichment: {relativePath}");
|
|
}
|
|
}
|
|
|
|
private static string FindRepoRoot()
|
|
{
|
|
var dir = AppContext.BaseDirectory;
|
|
for (var i = 0; i < 10 && !string.IsNullOrWhiteSpace(dir); i++)
|
|
{
|
|
if (File.Exists(Path.Combine(dir, "CBDDC.slnx")))
|
|
{
|
|
return dir;
|
|
}
|
|
|
|
dir = Directory.GetParent(dir)?.FullName ?? string.Empty;
|
|
}
|
|
|
|
throw new InvalidOperationException("Could not locate repository root containing CBDDC.slnx.");
|
|
}
|
|
|
|
private static bool HasCycle(Dictionary<string, HashSet<string>> graph)
|
|
{
|
|
var visiting = new HashSet<string>(StringComparer.Ordinal);
|
|
var visited = new HashSet<string>(StringComparer.Ordinal);
|
|
|
|
bool Dfs(string node)
|
|
{
|
|
if (visiting.Contains(node))
|
|
{
|
|
return true;
|
|
}
|
|
|
|
if (!visited.Add(node))
|
|
{
|
|
return false;
|
|
}
|
|
|
|
visiting.Add(node);
|
|
foreach (var next in graph[node])
|
|
{
|
|
if (Dfs(next))
|
|
{
|
|
return true;
|
|
}
|
|
}
|
|
|
|
visiting.Remove(node);
|
|
return false;
|
|
}
|
|
|
|
return graph.Keys.Any(Dfs);
|
|
}
|
|
}
|