namespace BuildFlow.Architecture.Tests;
using System.Xml.Linq;
using Xunit;
///
/// BuildFlow Host 架构约束测试。确保后台只有 BuildFlow Host 一个启动入口,
/// 业务模块(Domain/Application/Infrastructure 三层)不能退化为独立进程。
/// HTTP 层已统一并入 BuildFlow.Host,模块不再保留 Api 类库。
///
public class HostArchitectureTests
{
[Fact]
public void BusinessModulesShouldNotContainStandaloneProgramEntryPoints()
{
var repoRoot = FindRepoRoot();
var moduleRoot = Path.Combine(repoRoot.FullName, "src", "modules");
var programFiles = Directory
.EnumerateFiles(moduleRoot, "Program.cs", SearchOption.AllDirectories)
.Where(path => !ContainsPathSegment(path, "bin") && !ContainsPathSegment(path, "obj"))
.Where(path => !IsSsoPath(repoRoot, path))
.Select(path => Path.GetRelativePath(repoRoot.FullName, path).Replace('\\', '/'))
.Order(StringComparer.Ordinal)
.ToArray();
Assert.True(
programFiles.Length == 0,
"业务模块不允许包含独立 Program.cs,后台应只由 src/BuildFlow.Host/Program.cs 统一启动。当前残留:" +
Environment.NewLine +
string.Join(Environment.NewLine, programFiles));
}
[Fact]
public void BusinessModulesShouldNotContainLaunchProfiles()
{
var repoRoot = FindRepoRoot();
var moduleRoot = Path.Combine(repoRoot.FullName, "src", "modules");
var launchProfiles = Directory
.EnumerateFiles(moduleRoot, "launchSettings.json", SearchOption.AllDirectories)
.Where(path => !ContainsPathSegment(path, "bin") && !ContainsPathSegment(path, "obj"))
.Where(path => !IsSsoPath(repoRoot, path))
.Select(path => Path.GetRelativePath(repoRoot.FullName, path).Replace('\\', '/'))
.Order(StringComparer.Ordinal)
.ToArray();
Assert.True(
launchProfiles.Length == 0,
"业务模块不允许保留独立启动配置 launchSettings.json,启动配置应集中在 BuildFlow Host。当前残留:" +
Environment.NewLine +
string.Join(Environment.NewLine, launchProfiles));
}
[Fact]
public void BusinessModulesShouldNotHaveStandaloneApiProjects()
{
var repoRoot = FindRepoRoot();
var moduleRoot = Path.Combine(repoRoot.FullName, "src", "modules");
// 合并后,业务模块(rag/file/im/workflow)不应再有 Api 类库项目;仅 SSO 保留独立 Api
var apiProjects = Directory
.EnumerateFiles(moduleRoot, "*.Api.csproj", SearchOption.AllDirectories)
.Concat(Directory.EnumerateFiles(moduleRoot, "*Api.csproj", SearchOption.AllDirectories))
.Where(path => !ContainsPathSegment(path, "bin") && !ContainsPathSegment(path, "obj"))
.Where(path => !IsSsoPath(repoRoot, path))
.Distinct(StringComparer.Ordinal)
.Select(path => Path.GetRelativePath(repoRoot.FullName, path).Replace('\\', '/'))
.Order(StringComparer.Ordinal)
.ToArray();
Assert.True(
apiProjects.Length == 0,
"业务模块不允许保留独立 Api 类库项目(HTTP 层已统一并入 BuildFlow.Host)。当前残留:" +
Environment.NewLine +
string.Join(Environment.NewLine, apiProjects));
}
[Fact]
public void SsoApiShouldRemainStandaloneIdentityProvider()
{
var repoRoot = FindRepoRoot();
var ssoApiDir = Path.Combine(repoRoot.FullName, "src", "modules", "sso", "Api");
var ssoProjectPath = Path.Combine(ssoApiDir, "SSO.Api.csproj");
var violations = new List();
if (!File.Exists(Path.Combine(ssoApiDir, "Program.cs")))
{
violations.Add("src/modules/sso/Api/Program.cs");
}
if (HasLibraryOutputType(ssoProjectPath))
{
violations.Add("src/modules/sso/Api/SSO.Api.csproj 设置了 Library");
}
Assert.True(
violations.Count == 0,
"SSO 是独立身份提供方,不归 BuildFlow Host 合并启动;它必须保留自己的进程入口。当前问题:" +
Environment.NewLine +
string.Join(Environment.NewLine, violations));
}
private static DirectoryInfo FindRepoRoot()
{
var current = new DirectoryInfo(AppContext.BaseDirectory);
while (current is not null)
{
if (File.Exists(Path.Combine(current.FullName, "BuildFlow.slnx")))
{
return current;
}
current = current.Parent;
}
throw new InvalidOperationException("无法定位 BuildFlow.slnx,架构测试无法确定仓库边界。");
}
private static bool ContainsPathSegment(string path, string segment)
{
return path.Split(Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar)
.Any(part => string.Equals(part, segment, StringComparison.Ordinal));
}
private static bool HasLibraryOutputType(string projectPath)
{
var document = XDocument.Load(projectPath);
return document
.Descendants("OutputType")
.Any(element => string.Equals(element.Value.Trim(), "Library", StringComparison.Ordinal));
}
private static bool IsSsoPath(DirectoryInfo repoRoot, string path)
{
var relativePath = Path.GetRelativePath(repoRoot.FullName, path).Replace('\\', '/');
return relativePath.StartsWith("src/modules/sso/", StringComparison.Ordinal);
}
}