backend phase 0: foundation, cross-cutting seams & starter cleanup
Remove the Order demo (entity/feature/repo/config/gRPC/proto) and the three pre-marketplace migrations; regenerate a fresh InitialBaseline migration. Stand up the REST surface (PingController + System/Ping CQRS) proving the Mediator -> behaviors -> OperationResult -> ApiResult envelope end to end. Close wiring gaps: register LoggingBehavior (outermost) and add the built-in rate limiter (per-IP global + otp/auth/sensitive policies), placed before authentication. Add current-user + audit plumbing: ICurrentUser (HttpContext + null impls), rename BaseEntity audit fields to CreatedAt/ModifiedAt (DateTimeOffset) + CreatedById/ModifiedById, stamped by a new AuditFieldInterceptor. Introduce five cross-cutting seams (IDateTimeProvider, IFieldEncryptor, ICacheService, IObjectStorage, INotificationDispatcher) with in-memory/local mocks registered via AddCrossCuttingSeams. Add Baya.Test.Foundation (encryptor, audit interceptor, ping handler) and update docs, contracts (swagger.v1.json), handoff, report, and mocks registry.
This commit is contained in:
@@ -0,0 +1,95 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Domain.Common;
|
||||
using Baya.Infrastructure.Persistence.Interceptors;
|
||||
using Microsoft.Data.Sqlite;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Baya.Test.Foundation;
|
||||
|
||||
public class AuditFieldInterceptorTests
|
||||
{
|
||||
// A throwaway auditable entity + context so the interceptor is tested in isolation, without the
|
||||
// full Identity schema.
|
||||
private sealed class Widget : BaseEntity
|
||||
{
|
||||
public string Name { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class AuditTestDbContext(DbContextOptions options) : DbContext(options)
|
||||
{
|
||||
public DbSet<Widget> Widgets => Set<Widget>();
|
||||
}
|
||||
|
||||
private static AuditTestDbContext CreateContext(AuditFieldInterceptor interceptor)
|
||||
{
|
||||
var connection = new SqliteConnection("DataSource=:memory:");
|
||||
connection.Open();
|
||||
|
||||
var options = new DbContextOptionsBuilder<AuditTestDbContext>()
|
||||
.UseSqlite(connection)
|
||||
.AddInterceptors(interceptor)
|
||||
.Options;
|
||||
|
||||
var context = new AuditTestDbContext(options);
|
||||
context.Database.EnsureCreated();
|
||||
return context;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SavingChanges_OnAdd_StampsCreatedAtAndCreatedById()
|
||||
{
|
||||
// Arrange
|
||||
var fixedNow = new DateTimeOffset(2026, 6, 28, 10, 0, 0, TimeSpan.Zero);
|
||||
|
||||
var clock = Substitute.For<IDateTimeProvider>();
|
||||
clock.UtcNow.Returns(fixedNow);
|
||||
|
||||
var currentUser = Substitute.For<ICurrentUser>();
|
||||
currentUser.UserId.Returns(42);
|
||||
|
||||
await using var context = CreateContext(new AuditFieldInterceptor(currentUser, clock));
|
||||
var widget = new Widget { Name = "test" };
|
||||
context.Widgets.Add(widget);
|
||||
|
||||
// Act
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(fixedNow, widget.CreatedAt);
|
||||
Assert.Equal(fixedNow, widget.ModifiedAt);
|
||||
Assert.Equal(42, widget.CreatedById);
|
||||
Assert.Equal(42, widget.ModifiedById);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SavingChanges_OnUpdate_StampsModifiedButNotCreatedBy()
|
||||
{
|
||||
// Arrange
|
||||
var createNow = new DateTimeOffset(2026, 6, 28, 10, 0, 0, TimeSpan.Zero);
|
||||
var updateNow = new DateTimeOffset(2026, 6, 28, 12, 0, 0, TimeSpan.Zero);
|
||||
|
||||
var clock = Substitute.For<IDateTimeProvider>();
|
||||
var currentUser = Substitute.For<ICurrentUser>();
|
||||
|
||||
clock.UtcNow.Returns(createNow);
|
||||
currentUser.UserId.Returns(7);
|
||||
|
||||
await using var context = CreateContext(new AuditFieldInterceptor(currentUser, clock));
|
||||
var widget = new Widget { Name = "before" };
|
||||
context.Widgets.Add(widget);
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// Act — a different user edits the row later.
|
||||
clock.UtcNow.Returns(updateNow);
|
||||
currentUser.UserId.Returns(9);
|
||||
widget.Name = "after";
|
||||
await context.SaveChangesAsync();
|
||||
|
||||
// Assert
|
||||
Assert.Equal(createNow, widget.CreatedAt);
|
||||
Assert.Equal(7, widget.CreatedById);
|
||||
Assert.Equal(updateNow, widget.ModifiedAt);
|
||||
Assert.Equal(9, widget.ModifiedById);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
<Nullable>enable</Nullable>
|
||||
|
||||
<IsPackable>false</IsPackable>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" />
|
||||
<PackageReference Include="NSubstitute" />
|
||||
<PackageReference Include="xunit" />
|
||||
<PackageReference Include="xunit.runner.visualstudio">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="coverlet.collector">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Baya.Tests.Setup\Baya.Tests.Setup.csproj" />
|
||||
<ProjectReference Include="..\..\Infrastructure\Baya.Infrastructure.CrossCutting\Baya.Infrastructure.CrossCutting.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
@@ -0,0 +1,28 @@
|
||||
using Baya.Application.Contracts.Common;
|
||||
using Baya.Application.Features.System.Queries.Ping;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Baya.Test.Foundation;
|
||||
|
||||
public class PingQueryHandlerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsSuccess_WithServerTimeFromProvider()
|
||||
{
|
||||
// Arrange
|
||||
var now = new DateTimeOffset(2026, 6, 28, 9, 30, 0, TimeSpan.Zero);
|
||||
var clock = Substitute.For<IDateTimeProvider>();
|
||||
clock.UtcNow.Returns(now);
|
||||
|
||||
var handler = new PingQueryHandler(clock);
|
||||
|
||||
// Act
|
||||
var result = await handler.Handle(new PingQuery(), CancellationToken.None);
|
||||
|
||||
// Assert
|
||||
Assert.True(result.IsSuccess);
|
||||
Assert.NotNull(result.Result);
|
||||
Assert.Equal("ok", result.Result!.Status);
|
||||
Assert.Equal(now, result.Result.ServerTimeUtc);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
using Baya.Infrastructure.CrossCutting.Seams;
|
||||
using Microsoft.Extensions.Options;
|
||||
|
||||
namespace Baya.Test.Foundation;
|
||||
|
||||
public class SymmetricFieldEncryptorTests
|
||||
{
|
||||
private static SymmetricFieldEncryptor CreateEncryptor()
|
||||
{
|
||||
var options = Options.Create(new SeamOptions
|
||||
{
|
||||
FieldEncryption = new FieldEncryptionOptions
|
||||
{
|
||||
Key = "unit-test-field-encryption-key",
|
||||
HashKey = "unit-test-hash-key"
|
||||
}
|
||||
});
|
||||
|
||||
return new SymmetricFieldEncryptor(options);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Decrypt_OfEncrypt_ReturnsOriginalPlaintext()
|
||||
{
|
||||
// Arrange
|
||||
var encryptor = CreateEncryptor();
|
||||
const string plaintext = "09123456789";
|
||||
|
||||
// Act
|
||||
var cipher = encryptor.Encrypt(plaintext);
|
||||
var roundTripped = encryptor.Decrypt(cipher);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(plaintext, cipher);
|
||||
Assert.Equal(plaintext, roundTripped);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Encrypt_SameInputTwice_ProducesDifferentCiphertext()
|
||||
{
|
||||
// Arrange — a random IV per call means ciphertext is not deterministic (semantic security).
|
||||
var encryptor = CreateEncryptor();
|
||||
const string plaintext = "IR820540102680020817909002";
|
||||
|
||||
// Act
|
||||
var first = encryptor.Encrypt(plaintext);
|
||||
var second = encryptor.Encrypt(plaintext);
|
||||
|
||||
// Assert
|
||||
Assert.NotEqual(first, second);
|
||||
Assert.Equal(plaintext, encryptor.Decrypt(first));
|
||||
Assert.Equal(plaintext, encryptor.Decrypt(second));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Hash_IsDeterministic_ForLookups()
|
||||
{
|
||||
// Arrange
|
||||
var encryptor = CreateEncryptor();
|
||||
const string value = "IR820540102680020817909002";
|
||||
|
||||
// Act
|
||||
var first = encryptor.Hash(value);
|
||||
var second = encryptor.Hash(value);
|
||||
|
||||
// Assert
|
||||
Assert.Equal(first, second);
|
||||
Assert.NotEqual(value, first);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
global using Xunit;
|
||||
Reference in New Issue
Block a user