chore: add hrynco common library solution
- add the standalone HrynCo.Common solution and projects - include the shared common library source and tests - add package metadata and a repo gitignore
This commit is contained in:
@@ -0,0 +1,50 @@
|
||||
namespace HrynCo.Common.Tests;
|
||||
|
||||
using System.ComponentModel.DataAnnotations;
|
||||
using HrynCo.Common.HealthChecks;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Xunit;
|
||||
|
||||
public sealed class BaseConfigurationCheckTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CheckConfigurationAsync_ShouldReturnHealthyWhenOptionsAreValid()
|
||||
{
|
||||
var check = new FakeConfigurationCheck(new SampleOptions
|
||||
{
|
||||
Code = "ok"
|
||||
}, "Sample");
|
||||
|
||||
HealthCheckResult result = await check.CheckConfigurationAsync(default);
|
||||
|
||||
result.Status.Should().Be(HealthStatus.Healthy);
|
||||
result.Description.Should().Be("Sample configuration is valid.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CheckConfigurationAsync_ShouldReturnUnhealthyWhenOptionsAreInvalid()
|
||||
{
|
||||
var check = new FakeConfigurationCheck(new SampleOptions(), "Sample");
|
||||
|
||||
HealthCheckResult result = await check.CheckConfigurationAsync(default);
|
||||
|
||||
result.Status.Should().Be(HealthStatus.Unhealthy);
|
||||
result.Description.Should().Contain("Sample configuration invalid:");
|
||||
result.Description.Should().Contain("The Code field is required.");
|
||||
}
|
||||
|
||||
private sealed class FakeConfigurationCheck : BaseConfigurationCheck<SampleOptions>
|
||||
{
|
||||
public FakeConfigurationCheck(SampleOptions options, string name)
|
||||
: base(options, name)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class SampleOptions
|
||||
{
|
||||
[Required]
|
||||
public string? Code { get; set; }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
namespace HrynCo.Common.Tests;
|
||||
|
||||
using HrynCo.Common.Caching;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Caching.Memory;
|
||||
using Xunit;
|
||||
|
||||
public sealed class CachingTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task SessionCredentialStore_ShouldSaveGetAndRemoveValues()
|
||||
{
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var store = new InMemorySessionCredentialStore(cache);
|
||||
Guid userId = Guid.NewGuid();
|
||||
|
||||
await store.SaveAsync(userId, "github", "secret", TimeSpan.FromMinutes(5));
|
||||
|
||||
(await store.GetAsync(userId, "github")).Should().Be("secret");
|
||||
|
||||
await store.RemoveAsync(userId, "github");
|
||||
|
||||
(await store.GetAsync(userId, "github")).Should().BeNull();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SessionPromptStore_ShouldSaveGetAndRemoveValues()
|
||||
{
|
||||
using var cache = new MemoryCache(new MemoryCacheOptions());
|
||||
var store = new InMemorySessionPromptStore(cache);
|
||||
Guid userId = Guid.NewGuid();
|
||||
|
||||
await store.SaveAsync(userId, "openai", "prompt", TimeSpan.FromMinutes(5));
|
||||
|
||||
(await store.GetAsync(userId, "openai")).Should().Be("prompt");
|
||||
|
||||
await store.RemoveAsync(userId, "openai");
|
||||
|
||||
(await store.GetAsync(userId, "openai")).Should().BeNull();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
namespace HrynCo.Common.Tests;
|
||||
|
||||
using HrynCo.Common;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
public sealed class ClockTests
|
||||
{
|
||||
[Fact]
|
||||
public void Clock_ShouldExposeUtcAndLocalTimeCloseToConstructionTime()
|
||||
{
|
||||
DateTimeOffset initialUtcNow = new(2026, 04, 30, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
var clock = new Clock(initialUtcNow);
|
||||
|
||||
clock.UtcNow.Should().BeCloseTo(initialUtcNow, TimeSpan.FromSeconds(1));
|
||||
clock.Now.Should().BeCloseTo(initialUtcNow.ToLocalTime(), TimeSpan.FromSeconds(1));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNextDayOfWeek_ShouldReturnNextMatchingDay()
|
||||
{
|
||||
var clock = new Clock(new DateTimeOffset(2026, 04, 30, 12, 0, 0, TimeSpan.Zero));
|
||||
|
||||
clock.GetNextDayOfWeek(DayOfWeek.Friday).Should().Be(new DateOnly(2026, 5, 1));
|
||||
clock.GetNextDayOfWeek(DayOfWeek.Thursday).Should().Be(new DateOnly(2026, 5, 7));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetLastDayOfMonth_ShouldReturnMonthEnd()
|
||||
{
|
||||
var clock = new Clock(new DateTimeOffset(2026, 02, 10, 8, 0, 0, TimeSpan.Zero));
|
||||
|
||||
clock.GetLastDayOfMonth().Should().Be(new DateOnly(2026, 2, 28));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void GetNextMonthStart_ShouldReturnFirstDayOfNextMonth()
|
||||
{
|
||||
var clock = new Clock(new DateTimeOffset(2026, 12, 15, 8, 0, 0, TimeSpan.Zero));
|
||||
|
||||
clock.GetNextMonthStart().Should().Be(new DateOnly(2027, 1, 1));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
namespace HrynCo.Common.Tests;
|
||||
|
||||
using HrynCo.Common;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Configuration;
|
||||
using Xunit;
|
||||
|
||||
public sealed class ConfigurationFactoryTests
|
||||
{
|
||||
[Fact]
|
||||
public void CreateConfiguration_ShouldLoadJsonAndEnvironmentValues()
|
||||
{
|
||||
string basePath = CreateTempDirectory();
|
||||
string originalEnvironment = Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") ?? string.Empty;
|
||||
|
||||
try
|
||||
{
|
||||
File.WriteAllText(Path.Combine(basePath, "appsettings.json"), """
|
||||
{
|
||||
"Title": "base",
|
||||
"Nested": {
|
||||
"Value": "base"
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
File.WriteAllText(Path.Combine(basePath, "appsettings.Test.json"), """
|
||||
{
|
||||
"Nested": {
|
||||
"Value": "override"
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
File.WriteAllText(Path.Combine(basePath, "local.settings.json"), """
|
||||
{
|
||||
"Local": "enabled"
|
||||
}
|
||||
""");
|
||||
|
||||
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", "Test");
|
||||
Environment.SetEnvironmentVariable("TITLE", "from-env");
|
||||
|
||||
IConfigurationRoot configuration = ConfigurationFactory.CreateConfiguration(basePath);
|
||||
|
||||
configuration["Title"].Should().Be("from-env");
|
||||
configuration["Nested:Value"].Should().Be("override");
|
||||
configuration["Local"].Should().Be("enabled");
|
||||
}
|
||||
finally
|
||||
{
|
||||
Environment.SetEnvironmentVariable("ASPNETCORE_ENVIRONMENT", originalEnvironment);
|
||||
Environment.SetEnvironmentVariable("TITLE", null);
|
||||
Directory.Delete(basePath, true);
|
||||
}
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateConfiguration_ShouldThrowWhenBasePathIsMissing()
|
||||
{
|
||||
string missingPath = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
|
||||
Action act = () => ConfigurationFactory.CreateConfiguration(missingPath, "Production");
|
||||
|
||||
act.Should().Throw<DirectoryNotFoundException>()
|
||||
.WithMessage($"Directory {missingPath} does not exist. Wrong value of the basePath parameter.");
|
||||
}
|
||||
|
||||
private static string CreateTempDirectory()
|
||||
{
|
||||
string path = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString("N"));
|
||||
Directory.CreateDirectory(path);
|
||||
return path;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
global using System.Diagnostics.CodeAnalysis;
|
||||
@@ -0,0 +1,99 @@
|
||||
namespace HrynCo.Common.Tests;
|
||||
|
||||
using HrynCo.Common.HealthChecks;
|
||||
using HrynCo.Common.HealthChecks.Defaults;
|
||||
using HrynCo.Common.HealthChecks.Interfaces;
|
||||
using FluentAssertions;
|
||||
using Microsoft.Extensions.Diagnostics.HealthChecks;
|
||||
using Xunit;
|
||||
|
||||
public sealed class HealthChecksTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task CompositeHealthCheck_ShouldUseConfigurationCheckWhenConfiguredForConfigOnly()
|
||||
{
|
||||
var configCheck = new FakeConfigurationCheck();
|
||||
var serviceCheck = new FakeServiceHealthCheck();
|
||||
var healthCheck = new CompositeHealthCheck(configCheck, serviceCheck, configOnly: true);
|
||||
|
||||
HealthCheckResult result = await healthCheck.CheckHealthAsync(new HealthCheckContext(), default);
|
||||
|
||||
result.Status.Should().Be(HealthStatus.Healthy);
|
||||
configCheck.Called.Should().BeTrue();
|
||||
serviceCheck.Called.Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CompositeHealthCheck_ShouldUseServiceCheckWhenNotConfigOnly()
|
||||
{
|
||||
var configCheck = new FakeConfigurationCheck();
|
||||
var serviceCheck = new FakeServiceHealthCheck();
|
||||
var healthCheck = new CompositeHealthCheck(configCheck, serviceCheck, configOnly: false);
|
||||
|
||||
HealthCheckResult result = await healthCheck.CheckHealthAsync(new HealthCheckContext(), default);
|
||||
|
||||
result.Status.Should().Be(HealthStatus.Healthy);
|
||||
configCheck.Called.Should().BeFalse();
|
||||
serviceCheck.Called.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DbServiceHealthCheck_ShouldReturnHealthyWhenConnectionSucceeds()
|
||||
{
|
||||
var check = new DbServiceHealthCheck(new FakeDatabaseConnectionChecker(true));
|
||||
|
||||
HealthCheckResult result = await check.CheckHealthAsync(default);
|
||||
|
||||
result.Status.Should().Be(HealthStatus.Healthy);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DbServiceHealthCheck_ShouldReturnUnhealthyWhenConnectionFails()
|
||||
{
|
||||
var check = new DbServiceHealthCheck(new FakeDatabaseConnectionChecker(false));
|
||||
|
||||
HealthCheckResult result = await check.CheckHealthAsync(default);
|
||||
|
||||
result.Status.Should().Be(HealthStatus.Unhealthy);
|
||||
}
|
||||
|
||||
private sealed class FakeConfigurationCheck : IConfigurationCheck
|
||||
{
|
||||
public bool Called { get; private set; }
|
||||
|
||||
public Task<HealthCheckResult> CheckConfigurationAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_ = cancellationToken;
|
||||
Called = true;
|
||||
return Task.FromResult(HealthCheckResult.Healthy("config"));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeServiceHealthCheck : IServiceHealthCheck
|
||||
{
|
||||
public bool Called { get; private set; }
|
||||
|
||||
public Task<HealthCheckResult> CheckHealthAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_ = cancellationToken;
|
||||
Called = true;
|
||||
return Task.FromResult(HealthCheckResult.Healthy("service"));
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeDatabaseConnectionChecker : IDatabaseConnectionChecker
|
||||
{
|
||||
private readonly bool _canConnect;
|
||||
|
||||
public FakeDatabaseConnectionChecker(bool canConnect)
|
||||
{
|
||||
_canConnect = canConnect;
|
||||
}
|
||||
|
||||
public Task<bool> CanConnectAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
_ = cancellationToken;
|
||||
return Task.FromResult(_canConnect);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="8.2.0"/>
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.0"/>
|
||||
<PackageReference Include="xunit" Version="2.9.3"/>
|
||||
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.0">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HrynCo.Common\HrynCo.Common.csproj"/>
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,94 @@
|
||||
namespace HrynCo.Common.Tests;
|
||||
|
||||
using System.Collections.Concurrent;
|
||||
using HrynCo.Common;
|
||||
using FluentAssertions;
|
||||
using Serilog;
|
||||
using Serilog.Core;
|
||||
using Serilog.Events;
|
||||
using Xunit;
|
||||
|
||||
public sealed class ProfilerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task MeasureExecutionAsync_ShouldReturnResultAndWriteStartAndEndEvents()
|
||||
{
|
||||
var sink = new CollectingSink();
|
||||
ILogger logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Verbose()
|
||||
.WriteTo.Sink(sink)
|
||||
.CreateLogger();
|
||||
|
||||
var profiler = new Profiler(logger);
|
||||
|
||||
int result = await profiler.MeasureExecutionAsync(async () =>
|
||||
{
|
||||
await Task.Delay(1);
|
||||
return 42;
|
||||
}, "LoadItems");
|
||||
|
||||
result.Should().Be(42);
|
||||
sink.Events.Count.Should().BeGreaterThan(1);
|
||||
sink.Events.Should().ContainSingle(e =>
|
||||
e.Level == LogEventLevel.Information
|
||||
&& e.MessageTemplate.Text.Contains("Start", StringComparison.Ordinal)
|
||||
&& e.Properties["BlockName"].ToString().Contains("LoadItems", StringComparison.Ordinal));
|
||||
sink.Events.Should().ContainSingle(e =>
|
||||
e.Level == LogEventLevel.Information
|
||||
&& e.MessageTemplate.Text.Contains("End", StringComparison.Ordinal)
|
||||
&& e.Properties["Measurements"].ToString().Contains("True", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MeasureExecutionAsync_ActionOverload_ShouldInvokeAction()
|
||||
{
|
||||
var sink = new CollectingSink();
|
||||
ILogger logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Verbose()
|
||||
.WriteTo.Sink(sink)
|
||||
.CreateLogger();
|
||||
|
||||
var profiler = new Profiler(logger);
|
||||
bool invoked = false;
|
||||
|
||||
await profiler.MeasureExecutionAsync(async () =>
|
||||
{
|
||||
invoked = true;
|
||||
await Task.CompletedTask;
|
||||
}, "ActionBlock");
|
||||
|
||||
invoked.Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MeasureExecutionAsync_ShouldLogErrorAndRethrow()
|
||||
{
|
||||
var sink = new CollectingSink();
|
||||
ILogger logger = new LoggerConfiguration()
|
||||
.MinimumLevel.Verbose()
|
||||
.WriteTo.Sink(sink)
|
||||
.CreateLogger();
|
||||
|
||||
var profiler = new Profiler(logger);
|
||||
|
||||
Func<Task> act = async () =>
|
||||
{
|
||||
await profiler.MeasureExecutionAsync<int>(() => throw new InvalidOperationException("boom"), "FailBlock");
|
||||
};
|
||||
|
||||
await act.Should().ThrowAsync<InvalidOperationException>();
|
||||
sink.Events.Should().Contain(e =>
|
||||
e.Level == LogEventLevel.Error
|
||||
&& e.MessageTemplate.Text.Contains("An error occurred", StringComparison.Ordinal));
|
||||
}
|
||||
|
||||
private sealed class CollectingSink : ILogEventSink
|
||||
{
|
||||
public ConcurrentQueue<LogEvent> Events { get; } = new();
|
||||
|
||||
public void Emit(LogEvent logEvent)
|
||||
{
|
||||
Events.Enqueue(logEvent);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
namespace HrynCo.Common.Tests;
|
||||
|
||||
using System.Security.Cryptography;
|
||||
using HrynCo.Common.Security;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
public sealed class SecretProtectorTests
|
||||
{
|
||||
[Fact]
|
||||
public void Constructor_ShouldRejectMissingKey()
|
||||
{
|
||||
Action act = () => _ = new SecretProtector(string.Empty);
|
||||
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("Secret encryption key is not configured.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_ShouldRejectInvalidKeyLength()
|
||||
{
|
||||
string key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(31));
|
||||
|
||||
Action act = () => _ = new SecretProtector(key);
|
||||
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("Secret encryption key must be 32 bytes encoded as Base64.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProtectAndUnprotect_ShouldRoundTripPlaintext()
|
||||
{
|
||||
string key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
var protector = new SecretProtector(key);
|
||||
|
||||
string protectedValue = protector.Protect("hello world");
|
||||
string plaintext = protector.Unprotect(protectedValue);
|
||||
|
||||
protectedValue.Should().StartWith("v1:");
|
||||
plaintext.Should().Be("hello world");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unprotect_ShouldRejectUnsupportedFormat()
|
||||
{
|
||||
string key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
var protector = new SecretProtector(key);
|
||||
|
||||
Action act = () => protector.Unprotect("v2:payload");
|
||||
|
||||
act.Should().Throw<InvalidOperationException>()
|
||||
.WithMessage("Unsupported protected value format.");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unprotect_ShouldFailWhenProtectedValueIsTamperedWith()
|
||||
{
|
||||
string key = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
var protector = new SecretProtector(key);
|
||||
string protectedValue = protector.Protect("hello world");
|
||||
string tamperedValue = protectedValue[..^1] + (protectedValue.EndsWith('A') ? 'B' : 'A');
|
||||
|
||||
Action act = () => protector.Unprotect(tamperedValue);
|
||||
|
||||
act.Should().Throw<CryptographicException>();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Unprotect_ShouldFailWhenUsingTheWrongKey()
|
||||
{
|
||||
string originalKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
string otherKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32));
|
||||
var protector = new SecretProtector(originalKey);
|
||||
var wrongProtector = new SecretProtector(otherKey);
|
||||
string protectedValue = protector.Protect("hello world");
|
||||
|
||||
Action act = () => wrongProtector.Unprotect(protectedValue);
|
||||
|
||||
act.Should().Throw<CryptographicException>();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
namespace HrynCo.Common.Tests;
|
||||
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
public sealed class TimeApiUsageGuardTests
|
||||
{
|
||||
private static readonly string[] ForbiddenPatterns =
|
||||
[
|
||||
"DateTime.Now",
|
||||
"DateTimeOffset.Now"
|
||||
];
|
||||
|
||||
[Fact]
|
||||
public void ProductionCode_ShouldNotUseLocalNowApis()
|
||||
{
|
||||
string solutionRoot = GetSolutionRoot();
|
||||
|
||||
var violations = Directory
|
||||
.EnumerateFiles(solutionRoot, "*.cs", SearchOption.AllDirectories)
|
||||
.Where(IsProductionSourceFile)
|
||||
.SelectMany(file => FindViolations(solutionRoot, file))
|
||||
.ToArray();
|
||||
|
||||
violations.Should().BeEmpty(
|
||||
"production code should use UTC-based APIs for persisted and serialized timestamps.{0}{1}",
|
||||
Environment.NewLine,
|
||||
string.Join(Environment.NewLine, violations));
|
||||
}
|
||||
|
||||
private static string GetSolutionRoot()
|
||||
{
|
||||
string? current = AppContext.BaseDirectory;
|
||||
|
||||
while (current is not null)
|
||||
{
|
||||
if (File.Exists(Path.Combine(current, "HrynCo.Common.sln")))
|
||||
{
|
||||
return current;
|
||||
}
|
||||
|
||||
current = Directory.GetParent(current)?.FullName;
|
||||
}
|
||||
|
||||
throw new DirectoryNotFoundException("Could not locate the solution root from the test assembly output.");
|
||||
}
|
||||
|
||||
private static bool IsProductionSourceFile(string filePath)
|
||||
{
|
||||
string normalizedPath = filePath.Replace('\\', '/');
|
||||
|
||||
return !normalizedPath.Contains("/bin/", StringComparison.OrdinalIgnoreCase)
|
||||
&& !normalizedPath.Contains("/obj/", StringComparison.OrdinalIgnoreCase)
|
||||
&& !normalizedPath.Contains("/Migrations/", StringComparison.OrdinalIgnoreCase)
|
||||
&& !normalizedPath.Contains(".Tests/", StringComparison.OrdinalIgnoreCase)
|
||||
&& !normalizedPath.Contains(".IntegrationTests/", StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static IEnumerable<string> FindViolations(string solutionRoot, string filePath)
|
||||
{
|
||||
string relativePath = Path.GetRelativePath(solutionRoot, filePath).Replace('\\', '/');
|
||||
string[] lines = File.ReadAllLines(filePath);
|
||||
|
||||
for (int lineIndex = 0; lineIndex < lines.Length; lineIndex++)
|
||||
{
|
||||
string line = lines[lineIndex];
|
||||
|
||||
foreach (string forbiddenPattern in ForbiddenPatterns)
|
||||
{
|
||||
if (line.Contains(forbiddenPattern, StringComparison.Ordinal))
|
||||
{
|
||||
yield return $"{relativePath}:{lineIndex + 1} contains forbidden API `{forbiddenPattern}`";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
namespace HrynCo.Common.Tests;
|
||||
|
||||
using HrynCo.Common.Tree;
|
||||
using FluentAssertions;
|
||||
using Xunit;
|
||||
|
||||
public sealed class FakeNode : ITreeNode<Guid>
|
||||
{
|
||||
public required Guid Id { get; init; }
|
||||
public Guid? ParentId { get; init; }
|
||||
}
|
||||
|
||||
public sealed class FakeNamedNode : ITreeNode<Guid>, INameNode
|
||||
{
|
||||
public required string Name { get; init; }
|
||||
public required Guid Id { get; init; }
|
||||
public Guid? ParentId { get; init; }
|
||||
}
|
||||
|
||||
public sealed class TreeUtilsTests
|
||||
{
|
||||
[Fact]
|
||||
public void CollectDescendants_ShouldReturnSelfAndAllChildren()
|
||||
{
|
||||
// Arrange
|
||||
var root = Guid.NewGuid();
|
||||
var child1 = Guid.NewGuid();
|
||||
var child2 = Guid.NewGuid();
|
||||
var nodes = new List<FakeNode>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = root,
|
||||
ParentId = null
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = child1,
|
||||
ParentId = root
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = child2,
|
||||
ParentId = child1
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = TreeUtils.CollectDescendants(root, nodes, Guid.Empty);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEquivalentTo(new[]
|
||||
{
|
||||
root, child1, child2
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildBreadcrumb_ShouldReturnPathFromRootToNode()
|
||||
{
|
||||
// Arrange
|
||||
var root = Guid.NewGuid();
|
||||
var child = Guid.NewGuid();
|
||||
var grandchild = Guid.NewGuid();
|
||||
|
||||
var map = new Dictionary<Guid, FakeNamedNode>
|
||||
{
|
||||
[root] = new()
|
||||
{
|
||||
Id = root,
|
||||
ParentId = null,
|
||||
Name = "Root"
|
||||
},
|
||||
[child] = new()
|
||||
{
|
||||
Id = child,
|
||||
ParentId = root,
|
||||
Name = "Child"
|
||||
},
|
||||
[grandchild] = new()
|
||||
{
|
||||
Id = grandchild,
|
||||
ParentId = child,
|
||||
Name = "Grandchild"
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = TreeUtils.BuildBreadcrumb(grandchild, map);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEquivalentTo(new[]
|
||||
{
|
||||
new BreadcrumbNode<Guid>(root, "Root"), new BreadcrumbNode<Guid>(child, "Child"),
|
||||
new BreadcrumbNode<Guid>(grandchild, "Grandchild")
|
||||
}, options => options.WithStrictOrdering());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CollectDescendants_ShouldReturnOnlyRootWhenThereAreNoChildren()
|
||||
{
|
||||
// Arrange
|
||||
var root = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
var result = TreeUtils.CollectDescendants(root, Array.Empty<FakeNode>(), Guid.Empty);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEquivalentTo(new[] { root });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CollectDescendants_ShouldHandleCyclesWithoutRepeatingNodes()
|
||||
{
|
||||
// Arrange
|
||||
var root = Guid.NewGuid();
|
||||
var child = Guid.NewGuid();
|
||||
var nodes = new List<FakeNode>
|
||||
{
|
||||
new()
|
||||
{
|
||||
Id = root,
|
||||
ParentId = child
|
||||
},
|
||||
new()
|
||||
{
|
||||
Id = child,
|
||||
ParentId = root
|
||||
}
|
||||
};
|
||||
|
||||
// Act
|
||||
var result = TreeUtils.CollectDescendants(root, nodes, Guid.Empty);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEquivalentTo(new[] { root, child });
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildBreadcrumb_ShouldReturnEmptyListWhenNodeIsMissing()
|
||||
{
|
||||
// Arrange
|
||||
var map = new Dictionary<Guid, FakeNamedNode>();
|
||||
|
||||
// Act
|
||||
var result = TreeUtils.BuildBreadcrumb(Guid.NewGuid(), map);
|
||||
|
||||
// Assert
|
||||
result.Should().BeEmpty();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user