From dd561bdef9e88409d6bd0382e721e409b3357280 Mon Sep 17 00:00:00 2001 From: "tadeas.zribko" Date: Sat, 4 Apr 2026 14:42:58 +0200 Subject: [PATCH 1/8] feat: Update project dependencies and improve test configurations - Added FluentValidation package version 12.1.1 to Directory.Packages.props for enhanced validation capabilities. - Included InternalsVisibleTo attribute in Notifications.csproj to facilitate testing. - Updated APITemplate.Tests.csproj with new package references for ErrorOr, FluentValidation, Microsoft.EntityFrameworkCore.InMemory, MockQueryable.Moq, and SystemTextJsonPatch. - Refactored various test files to improve structure, including updates to BackgroundJobsOptionsValidatorTests and InMemoryIdempotencyStoreTests for better validation logic and test coverage. - Enhanced HmacWebhookPayloadValidatorTests and OutgoingWebhookSsrfTests with additional validation scenarios and test data integration. --- Directory.Packages.props | 1 + .../Notifications/Notifications.csproj | 3 + .../APITemplate.Tests.csproj | 47 ++++++--- tests/APITemplate.Tests/AssemblyFixtures.cs | 5 +- .../BackgroundJobsOptionsValidatorTests.cs | 95 ++++++++++-------- .../TenantAwareOutputCachePolicyTests.cs | 8 ++ .../SseStreamRequestValidationTests.cs | 46 +++++++++ .../LocalFileStorageServiceTests.cs | 96 +++++++++++++++++++ .../Unit/Helpers/WebhookTestHelper.cs | 16 ++++ .../InMemoryIdempotencyStoreTests.cs | 29 +++++- .../Unit/Identity/EmailValueObjectTests.cs | 54 +++++++++++ .../Identity/TenantClaimValidatorTests.cs | 43 +++++++++ .../Identity/TenantCodeValueObjectTests.cs | 47 +++++++++ .../Logging/RedactionConfigurationTests.cs | 2 +- .../FailedEmailErrorNormalizerTests.cs | 35 +++++++ .../FluidEmailTemplateRendererTests.cs | 44 +++++++++ .../ProductCatalog/PriceValueObjectTests.cs | 49 ++++++++++ .../ProductSoftDeleteCascadeRuleTests.cs | 35 +++++++ .../ProductReviewFilterValidatorTests.cs | 74 ++++++++++++++ .../Unit/Reviews/RatingValueObjectTests.cs | 41 ++++++++ .../StoredProcedureExecutorTests.cs | 41 ++------ .../Unit/TestData/EmailTheoryData.cs | 19 ++++ .../Unit/TestData/PriceTheoryData.cs | 13 +++ .../Unit/TestData/SsrfTheoryData.cs | 23 +++++ .../CreateProductRequestValidatorTests.cs | 7 +- .../Webhooks/HmacWebhookPayloadSignerTests.cs | 4 +- .../HmacWebhookPayloadValidatorTests.cs | 22 ++++- .../Unit/Webhooks/OutgoingWebhookSsrfTests.cs | 8 +- 28 files changed, 800 insertions(+), 107 deletions(-) create mode 100644 tests/APITemplate.Tests/Unit/Chatting/SseStreamRequestValidationTests.cs create mode 100644 tests/APITemplate.Tests/Unit/FileStorage/LocalFileStorageServiceTests.cs create mode 100644 tests/APITemplate.Tests/Unit/Helpers/WebhookTestHelper.cs create mode 100644 tests/APITemplate.Tests/Unit/Identity/EmailValueObjectTests.cs create mode 100644 tests/APITemplate.Tests/Unit/Identity/TenantClaimValidatorTests.cs create mode 100644 tests/APITemplate.Tests/Unit/Identity/TenantCodeValueObjectTests.cs create mode 100644 tests/APITemplate.Tests/Unit/Notifications/FailedEmailErrorNormalizerTests.cs create mode 100644 tests/APITemplate.Tests/Unit/Notifications/FluidEmailTemplateRendererTests.cs create mode 100644 tests/APITemplate.Tests/Unit/ProductCatalog/PriceValueObjectTests.cs create mode 100644 tests/APITemplate.Tests/Unit/ProductCatalog/ProductSoftDeleteCascadeRuleTests.cs create mode 100644 tests/APITemplate.Tests/Unit/Reviews/ProductReviewFilterValidatorTests.cs create mode 100644 tests/APITemplate.Tests/Unit/Reviews/RatingValueObjectTests.cs create mode 100644 tests/APITemplate.Tests/Unit/TestData/EmailTheoryData.cs create mode 100644 tests/APITemplate.Tests/Unit/TestData/PriceTheoryData.cs create mode 100644 tests/APITemplate.Tests/Unit/TestData/SsrfTheoryData.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index bcf0c597..7f55b53e 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -15,6 +15,7 @@ + diff --git a/src/Modules/Notifications/Notifications.csproj b/src/Modules/Notifications/Notifications.csproj index 146a4142..029f137e 100644 --- a/src/Modules/Notifications/Notifications.csproj +++ b/src/Modules/Notifications/Notifications.csproj @@ -4,6 +4,9 @@ enable enable + + + diff --git a/tests/APITemplate.Tests/APITemplate.Tests.csproj b/tests/APITemplate.Tests/APITemplate.Tests.csproj index 7e8bd02b..168d7ac2 100644 --- a/tests/APITemplate.Tests/APITemplate.Tests.csproj +++ b/tests/APITemplate.Tests/APITemplate.Tests.csproj @@ -4,11 +4,15 @@ enable enable false - false + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive @@ -22,26 +26,39 @@ + - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/tests/APITemplate.Tests/AssemblyFixtures.cs b/tests/APITemplate.Tests/AssemblyFixtures.cs index 2e53fe53..d4da9b82 100644 --- a/tests/APITemplate.Tests/AssemblyFixtures.cs +++ b/tests/APITemplate.Tests/AssemblyFixtures.cs @@ -1,4 +1,3 @@ -using APITemplate.Tests.Integration.Postgres; -using Xunit; +namespace APITemplate.Tests; -[assembly: AssemblyFixture(typeof(SharedPostgresContainer))] +file static class AssemblyFixtures { } diff --git a/tests/APITemplate.Tests/Unit/BackgroundJobs/BackgroundJobsOptionsValidatorTests.cs b/tests/APITemplate.Tests/Unit/BackgroundJobs/BackgroundJobsOptionsValidatorTests.cs index 9b9989d7..b719efbd 100644 --- a/tests/APITemplate.Tests/Unit/BackgroundJobs/BackgroundJobsOptionsValidatorTests.cs +++ b/tests/APITemplate.Tests/Unit/BackgroundJobs/BackgroundJobsOptionsValidatorTests.cs @@ -1,5 +1,6 @@ -using APITemplate.Infrastructure.BackgroundJobs.Validation; -using SharedKernel.Application.Options; +using BackgroundJobs.Validation; +using Microsoft.Extensions.Options; +using SharedKernel.Application.Options.BackgroundJobs; using Shouldly; using Xunit; @@ -7,57 +8,73 @@ namespace APITemplate.Tests.Unit.BackgroundJobs; public sealed class BackgroundJobsOptionsValidatorTests { + private readonly BackgroundJobsOptionsValidator _sut = new(); + [Fact] - public void Validate_ReturnsSuccess_WhenDisabledJobsHaveInvalidValues() + public void Validate_WhenTickerQEnabledAndMissingPrefix_Fails() { - var sut = new BackgroundJobsOptionsValidator(); - var options = new BackgroundJobsOptions + BackgroundJobsOptions options = new() { - Cleanup = new CleanupJobOptions - { - Enabled = false, - Cron = "", - BatchSize = 0, - ExpiredInvitationRetentionHours = -1, - SoftDeleteRetentionDays = -1, - OrphanedProductDataRetentionDays = -1, - }, - Reindex = new ReindexJobOptions { Enabled = false, Cron = "" }, - ExternalSync = new ExternalSyncJobOptions { Enabled = false, Cron = "" }, - EmailRetry = new EmailRetryJobOptions - { - Enabled = false, - Cron = "", - BatchSize = 0, - MaxRetryAttempts = 0, - ClaimLeaseMinutes = 0, - DeadLetterAfterHours = -1, - }, + TickerQ = new TickerQSchedulerOptions { Enabled = true, InstanceNamePrefix = " " }, }; - var result = sut.Validate(name: null, options); + ValidateOptionsResult result = _sut.Validate(null, options); - result.Succeeded.ShouldBeTrue(); + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldContain("InstanceNamePrefix"); + } + + [Theory] + [InlineData("not valid cron")] + [InlineData("* * *")] + public void Validate_WhenCleanupEnabledAndCronInvalid_Fails(string cron) + { + BackgroundJobsOptions options = new() + { + Cleanup = new CleanupJobOptions { Enabled = true, Cron = cron }, + }; + + ValidateOptionsResult result = _sut.Validate(null, options); + + result.Failed.ShouldBeTrue(); + result.FailureMessage.ShouldContain("CRON"); + } + + [Theory] + [InlineData(-1)] + [InlineData(-3)] + public void Validate_WhenCleanupEnabledAndNegativeRetention_Fails(int days) + { + BackgroundJobsOptions options = new() + { + Cleanup = new CleanupJobOptions { Enabled = true, SoftDeleteRetentionDays = days }, + }; + + ValidateOptionsResult result = _sut.Validate(null, options); + + result.Failed.ShouldBeTrue(); } [Fact] - public void Validate_ReturnsFailure_WhenEnabledCleanupHasInvalidValues() + public void Validate_WhenEmailRetryEnabledAndBatchSizeZero_Fails() { - var sut = new BackgroundJobsOptionsValidator(); - var options = new BackgroundJobsOptions + BackgroundJobsOptions options = new() { - Cleanup = new CleanupJobOptions - { - Enabled = true, - Cron = "", - BatchSize = 0, - }, + EmailRetry = new EmailRetryJobOptions { Enabled = true, BatchSize = 0 }, }; - var result = sut.Validate(name: null, options); + ValidateOptionsResult result = _sut.Validate(null, options); result.Failed.ShouldBeTrue(); - result.Failures.ShouldContain("BackgroundJobs:Cleanup:Cron is required."); - result.Failures.ShouldContain("BackgroundJobs:Cleanup:BatchSize must be greater than 0."); + } + + [Fact] + public void Validate_WhenDefaultsAndJobsDisabled_Succeeds() + { + BackgroundJobsOptions options = new(); + + ValidateOptionsResult result = _sut.Validate(null, options); + + result.Succeeded.ShouldBeTrue(); } } diff --git a/tests/APITemplate.Tests/Unit/Cache/TenantAwareOutputCachePolicyTests.cs b/tests/APITemplate.Tests/Unit/Cache/TenantAwareOutputCachePolicyTests.cs index 4a845a45..8ea19850 100644 --- a/tests/APITemplate.Tests/Unit/Cache/TenantAwareOutputCachePolicyTests.cs +++ b/tests/APITemplate.Tests/Unit/Cache/TenantAwareOutputCachePolicyTests.cs @@ -1,4 +1,6 @@ +using System.Security.Claims; using APITemplate.Api.Cache; +using Identity.Security; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.OutputCaching; using Shouldly; @@ -62,6 +64,12 @@ private static OutputCacheContext CreateContext(string method = "GET") var httpContext = new DefaultHttpContext(); httpContext.Request.Method = method; httpContext.Request.Path = "/api/v1/products"; + httpContext.User = new ClaimsPrincipal( + new ClaimsIdentity( + [new Claim(AuthConstants.Claims.TenantId, Guid.NewGuid().ToString())], + authenticationType: "Test" + ) + ); return new OutputCacheContext { HttpContext = httpContext }; } } diff --git a/tests/APITemplate.Tests/Unit/Chatting/SseStreamRequestValidationTests.cs b/tests/APITemplate.Tests/Unit/Chatting/SseStreamRequestValidationTests.cs new file mode 100644 index 00000000..a1eac2e7 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/Chatting/SseStreamRequestValidationTests.cs @@ -0,0 +1,46 @@ +using System.ComponentModel.DataAnnotations; +using Chatting.Features.GetNotificationStream; +using Shouldly; +using Xunit; + +namespace APITemplate.Tests.Unit.Chatting; + +public sealed class SseStreamRequestValidationTests +{ + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(101)] + public void Validation_WhenCountOutOfRange_Fails(int count) + { + SseStreamRequest request = new() { Count = count }; + var results = new List(); + bool valid = Validator.TryValidateObject( + request, + new ValidationContext(request), + results, + validateAllProperties: true + ); + + valid.ShouldBeFalse(); + results.ShouldNotBeEmpty(); + } + + [Theory] + [InlineData(1)] + [InlineData(50)] + [InlineData(100)] + public void Validation_WhenCountInRange_Passes(int count) + { + SseStreamRequest request = new() { Count = count }; + var results = new List(); + bool valid = Validator.TryValidateObject( + request, + new ValidationContext(request), + results, + validateAllProperties: true + ); + + valid.ShouldBeTrue(); + } +} diff --git a/tests/APITemplate.Tests/Unit/FileStorage/LocalFileStorageServiceTests.cs b/tests/APITemplate.Tests/Unit/FileStorage/LocalFileStorageServiceTests.cs new file mode 100644 index 00000000..bffc385e --- /dev/null +++ b/tests/APITemplate.Tests/Unit/FileStorage/LocalFileStorageServiceTests.cs @@ -0,0 +1,96 @@ +using FileStorage.Contracts; +using FileStorage.Services; +using Microsoft.Extensions.Options; +using Moq; +using SharedKernel.Application.Context; +using Shouldly; +using Xunit; + +namespace APITemplate.Tests.Unit.FileStorage; + +public sealed class LocalFileStorageServiceTests : IDisposable +{ + private readonly string _basePath = Path.Combine( + Path.GetTempPath(), + "fs-u-" + Guid.NewGuid().ToString("N") + ); + private bool _disposed; + + public void Dispose() + { + if (_disposed) + return; + _disposed = true; + try + { + if (Directory.Exists(_basePath)) + Directory.Delete(_basePath, recursive: true); + } + catch + { + // best-effort cleanup for parallel CI + } + } + + [Fact] + public async Task SaveAsync_WritesFileUnderTenantDirectory() + { + Guid tenantId = Guid.NewGuid(); + Mock tenant = new(); + tenant.Setup(t => t.TenantId).Returns(tenantId); + + LocalFileStorageService sut = CreateSut(tenant.Object); + await using MemoryStream input = new("hello"u8.ToArray()); + + FileStorageResult result = await sut.SaveAsync( + input, + "doc.pdf", + TestContext.Current.CancellationToken + ); + + result.SizeBytes.ShouldBe(5); + File.Exists(result.StoragePath).ShouldBeTrue(); + result.StoragePath.ShouldStartWith(Path.Combine(_basePath, tenantId.ToString())); + } + + [Fact] + public async Task OpenReadAsync_WhenMissing_ReturnsNull() + { + LocalFileStorageService sut = CreateSut(CreateTenant()); + + Stream? stream = await sut.OpenReadAsync( + Path.Combine(_basePath, Guid.NewGuid().ToString(), "missing.bin"), + TestContext.Current.CancellationToken + ); + + stream.ShouldBeNull(); + } + + [Fact] + public async Task OpenReadAsync_WhenOutsideBasePath_Throws() + { + LocalFileStorageService sut = CreateSut(CreateTenant()); + + string outside = Path.GetFullPath(Path.Combine(_basePath, "..", "outside-test.txt")); + + await Should.ThrowAsync(() => + sut.OpenReadAsync(outside, TestContext.Current.CancellationToken) + ); + } + + private static ITenantProvider CreateTenant() + { + Mock tenant = new(); + tenant.Setup(t => t.TenantId).Returns(Guid.NewGuid()); + return tenant.Object; + } + + private LocalFileStorageService CreateSut(ITenantProvider tenant) + { + Directory.CreateDirectory(_basePath); + IOptions options = Options.Create( + new FileStorageOptions { BasePath = _basePath } + ); + return new LocalFileStorageService(options, tenant); + } +} diff --git a/tests/APITemplate.Tests/Unit/Helpers/WebhookTestHelper.cs b/tests/APITemplate.Tests/Unit/Helpers/WebhookTestHelper.cs new file mode 100644 index 00000000..4b6a22fd --- /dev/null +++ b/tests/APITemplate.Tests/Unit/Helpers/WebhookTestHelper.cs @@ -0,0 +1,16 @@ +using System.Security.Cryptography; +using System.Text; + +namespace APITemplate.Tests.Unit.Helpers; + +internal static class WebhookTestHelper +{ + internal static string ComputeHmacSignature(string body, string timestamp, string secret) + { + string signedContent = $"{timestamp}.{body}"; + byte[] keyBytes = Encoding.UTF8.GetBytes(secret); + byte[] contentBytes = Encoding.UTF8.GetBytes(signedContent); + byte[] hashBytes = HMACSHA256.HashData(keyBytes, contentBytes); + return Convert.ToHexStringLower(hashBytes); + } +} diff --git a/tests/APITemplate.Tests/Unit/Idempotency/InMemoryIdempotencyStoreTests.cs b/tests/APITemplate.Tests/Unit/Idempotency/InMemoryIdempotencyStoreTests.cs index 123c8e5e..5fa71ac5 100644 --- a/tests/APITemplate.Tests/Unit/Idempotency/InMemoryIdempotencyStoreTests.cs +++ b/tests/APITemplate.Tests/Unit/Idempotency/InMemoryIdempotencyStoreTests.cs @@ -5,18 +5,41 @@ namespace APITemplate.Tests.Unit.Idempotency; +/// +/// Covers in-process idempotency. depends on Redis Lua scripts; +/// add container-backed integration tests if multi-instance lock semantics need automated coverage. +/// public sealed class InMemoryIdempotencyStoreTests { private static readonly TimeSpan DefaultTtl = TimeSpan.FromMinutes(5); - [Fact] - public async Task TryAcquireAsync_WhenKeyIsNew_ReturnsLockToken() + [Theory] + [InlineData("key-1")] + [InlineData("tenant:scope:123")] + [InlineData("")] + public async Task TryAcquireAsync_WhenKeyIsNew_ReturnsLockToken(string key) { CancellationToken ct = TestContext.Current.CancellationToken; FakeTimeProvider time = new(DateTimeOffset.UtcNow); InMemoryIdempotencyStore store = new(time); - string? token = await store.TryAcquireAsync("key-1", DefaultTtl, ct); + string? token = await store.TryAcquireAsync(key, DefaultTtl, ct); + + token.ShouldNotBeNull(); + } + + [Theory] + [InlineData(0)] + [InlineData(1)] + [InlineData(3600000)] + public async Task TryAcquireAsync_VariousTtlsStillAcquires(int ttlMs) + { + CancellationToken ct = TestContext.Current.CancellationToken; + FakeTimeProvider time = new(DateTimeOffset.UtcNow); + InMemoryIdempotencyStore store = new(time); + string key = Guid.NewGuid().ToString("N"); + + string? token = await store.TryAcquireAsync(key, TimeSpan.FromMilliseconds(ttlMs), ct); token.ShouldNotBeNull(); } diff --git a/tests/APITemplate.Tests/Unit/Identity/EmailValueObjectTests.cs b/tests/APITemplate.Tests/Unit/Identity/EmailValueObjectTests.cs new file mode 100644 index 00000000..9771bfe1 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/Identity/EmailValueObjectTests.cs @@ -0,0 +1,54 @@ +using APITemplate.Tests.Unit.TestData; +using ErrorOr; +using Identity.ValueObjects; +using Shouldly; +using Xunit; + +namespace APITemplate.Tests.Unit.Identity; + +public sealed class EmailValueObjectTests +{ + [Theory] + [MemberData(nameof(EmailTheoryData.InvalidRawInputs), MemberType = typeof(EmailTheoryData))] + public void Create_WhenInvalid_ReturnsError(string? raw) + { + ErrorOr result = Email.Create(raw ?? string.Empty); + + result.IsError.ShouldBeTrue(); + } + + [Theory] + [MemberData( + nameof(EmailTheoryData.TrimmingAndNormalizationCases), + MemberType = typeof(EmailTheoryData) + )] + public void Create_WhenValid_TrimsInput(string raw, string expectedValue) + { + ErrorOr result = Email.Create(raw); + + result.IsError.ShouldBeFalse(); + result.Value.Value.ShouldBe(expectedValue); + } + + [Fact] + public void Create_WhenValid_CanRoundTripImplicitlyToString() + { + ErrorOr result = Email.Create("person@domain.example"); + + ((string)result.Value).ShouldBe("person@domain.example"); + } + + [Fact] + public void NormalizeRaw_TrimsAndUppercases() + { + Email.NormalizeRaw(" a@b.co ").ShouldBe("A@B.CO"); + } + + [Fact] + public void Normalize_OnEmail_UppercasesValue() + { + Email email = Email.Create("a@b.co").Value; + + email.Normalize().ShouldBe("A@B.CO"); + } +} diff --git a/tests/APITemplate.Tests/Unit/Identity/TenantClaimValidatorTests.cs b/tests/APITemplate.Tests/Unit/Identity/TenantClaimValidatorTests.cs new file mode 100644 index 00000000..014dc564 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/Identity/TenantClaimValidatorTests.cs @@ -0,0 +1,43 @@ +using System.Security.Claims; +using Identity.Security; +using Shouldly; +using Xunit; + +namespace APITemplate.Tests.Unit.Identity; + +public sealed class TenantClaimValidatorTests +{ + [Theory] + [InlineData("00000000-0000-0000-0000-000000000000")] + [InlineData("not-a-guid")] + [InlineData("")] + public void HasValidTenantClaim_WhenMissingOrInvalid_ReturnsFalse(string? tenantValue) + { + Claim[] claims = tenantValue is null + ? [] + : [new Claim(AuthConstants.Claims.TenantId, tenantValue)]; + ClaimsPrincipal principal = new(new ClaimsIdentity(claims, authenticationType: "Test")); + + TenantClaimValidator.HasValidTenantClaim(principal).ShouldBeFalse(); + } + + [Fact] + public void HasValidTenantClaim_WhenNonEmptyGuid_ReturnsTrue() + { + string tenantId = Guid.NewGuid().ToString(); + ClaimsPrincipal principal = new( + new ClaimsIdentity( + [new Claim(AuthConstants.Claims.TenantId, tenantId)], + authenticationType: "Test" + ) + ); + + TenantClaimValidator.HasValidTenantClaim(principal).ShouldBeTrue(); + } + + [Fact] + public void HasValidTenantClaim_WhenPrincipalNull_ReturnsFalse() + { + TenantClaimValidator.HasValidTenantClaim(null).ShouldBeFalse(); + } +} diff --git a/tests/APITemplate.Tests/Unit/Identity/TenantCodeValueObjectTests.cs b/tests/APITemplate.Tests/Unit/Identity/TenantCodeValueObjectTests.cs new file mode 100644 index 00000000..10d7470b --- /dev/null +++ b/tests/APITemplate.Tests/Unit/Identity/TenantCodeValueObjectTests.cs @@ -0,0 +1,47 @@ +using ErrorOr; +using Identity.ValueObjects; +using Shouldly; +using Xunit; + +namespace APITemplate.Tests.Unit.Identity; + +public sealed class TenantCodeValueObjectTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + public void Create_WhenEmpty_ReturnsError(string? raw) + { + ErrorOr result = TenantCode.Create(raw!); + + result.IsError.ShouldBeTrue(); + } + + [Fact] + public void Create_WhenLongerThan100_ReturnsError() + { + ErrorOr result = TenantCode.Create(new string('x', 101)); + + result.IsError.ShouldBeTrue(); + } + + [Theory] + [InlineData("acme", "acme")] + [InlineData(" acme ", "acme")] + public void Create_WhenValid_TrimsAndPreservesValue(string raw, string expected) + { + ErrorOr result = TenantCode.Create(raw); + + result.IsError.ShouldBeFalse(); + result.Value.Value.ShouldBe(expected); + } + + [Fact] + public void FromPersistence_BypassesValidation() + { + TenantCode code = TenantCode.FromPersistence("any"); + + code.Value.ShouldBe("any"); + } +} diff --git a/tests/APITemplate.Tests/Unit/Logging/RedactionConfigurationTests.cs b/tests/APITemplate.Tests/Unit/Logging/RedactionConfigurationTests.cs index c7176746..fe78b196 100644 --- a/tests/APITemplate.Tests/Unit/Logging/RedactionConfigurationTests.cs +++ b/tests/APITemplate.Tests/Unit/Logging/RedactionConfigurationTests.cs @@ -1,5 +1,5 @@ -using APITemplate.Infrastructure.Logging; using SharedKernel.Application.Options.Security; +using SharedKernel.Infrastructure.Logging; using Shouldly; using Xunit; diff --git a/tests/APITemplate.Tests/Unit/Notifications/FailedEmailErrorNormalizerTests.cs b/tests/APITemplate.Tests/Unit/Notifications/FailedEmailErrorNormalizerTests.cs new file mode 100644 index 00000000..ef373715 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/Notifications/FailedEmailErrorNormalizerTests.cs @@ -0,0 +1,35 @@ +using Notifications.Domain; +using Notifications.Services; +using Shouldly; +using Xunit; + +namespace APITemplate.Tests.Unit.Notifications; + +public sealed class FailedEmailErrorNormalizerTests +{ + [Theory] + [InlineData(null)] + [InlineData("")] + public void Normalize_WhenNullOrEmpty_ReturnsSame(string? error) + { + FailedEmailErrorNormalizer.Normalize(error).ShouldBe(error); + } + + [Fact] + public void Normalize_WhenShorterThanMax_ReturnsUnchanged() + { + string msg = new string('e', 100); + + FailedEmailErrorNormalizer.Normalize(msg).ShouldBe(msg); + } + + [Fact] + public void Normalize_WhenLongerThanMax_TruncatesToMaxLength() + { + string msg = new string('x', FailedEmail.LastErrorMaxLength + 50); + + string? normalized = FailedEmailErrorNormalizer.Normalize(msg); + + normalized!.Length.ShouldBe(FailedEmail.LastErrorMaxLength); + } +} diff --git a/tests/APITemplate.Tests/Unit/Notifications/FluidEmailTemplateRendererTests.cs b/tests/APITemplate.Tests/Unit/Notifications/FluidEmailTemplateRendererTests.cs new file mode 100644 index 00000000..1bc6c807 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/Notifications/FluidEmailTemplateRendererTests.cs @@ -0,0 +1,44 @@ +using Notifications.Contracts; +using Notifications.Services; +using Shouldly; +using Xunit; + +namespace APITemplate.Tests.Unit.Notifications; + +public sealed class FluidEmailTemplateRendererTests +{ + private readonly FluidEmailTemplateRenderer _sut = new(); + + [Fact] + public async Task RenderAsync_UserRegistration_SubstitutesModel() + { + var model = new + { + Username = "Ada", + Email = "ada@example.com", + LoginUrl = "https://app/login", + }; + + string html = await _sut.RenderAsync( + EmailTemplateNames.UserRegistration, + model, + TestContext.Current.CancellationToken + ); + + html.ShouldContain("Ada"); + html.ShouldContain("ada@example.com"); + html.ShouldContain("https://app/login"); + } + + [Fact] + public async Task RenderAsync_UnknownTemplate_Throws() + { + await Should.ThrowAsync(() => + _sut.RenderAsync( + "Does.Not.Exist.Template", + new { }, + TestContext.Current.CancellationToken + ) + ); + } +} diff --git a/tests/APITemplate.Tests/Unit/ProductCatalog/PriceValueObjectTests.cs b/tests/APITemplate.Tests/Unit/ProductCatalog/PriceValueObjectTests.cs new file mode 100644 index 00000000..3b1c4281 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/ProductCatalog/PriceValueObjectTests.cs @@ -0,0 +1,49 @@ +using APITemplate.Tests.Unit.TestData; +using ErrorOr; +using ProductCatalog.ValueObjects; +using Shouldly; +using Xunit; + +namespace APITemplate.Tests.Unit.ProductCatalog; + +public sealed class PriceValueObjectTests +{ + [Theory] + [MemberData( + nameof(PriceTheoryData.InvalidNegativeAmounts), + MemberType = typeof(PriceTheoryData) + )] + public void Create_WhenNegative_ReturnsError(decimal amount) + { + ErrorOr result = Price.Create(amount); + + result.IsError.ShouldBeTrue(); + } + + [Theory] + [MemberData( + nameof(PriceTheoryData.ValidNonNegativeAmounts), + MemberType = typeof(PriceTheoryData) + )] + public void Create_WhenNonNegative_ReturnsPrice(decimal amount) + { + ErrorOr result = Price.Create(amount); + + result.IsError.ShouldBeFalse(); + result.Value.Value.ShouldBe(amount); + } + + [Fact] + public void Zero_IsZero() + { + Price.Zero.Value.ShouldBe(0m); + } + + [Fact] + public void FromPersistence_DoesNotRejectNegative() + { + Price p = Price.FromPersistence(-5m); + + p.Value.ShouldBe(-5m); + } +} diff --git a/tests/APITemplate.Tests/Unit/ProductCatalog/ProductSoftDeleteCascadeRuleTests.cs b/tests/APITemplate.Tests/Unit/ProductCatalog/ProductSoftDeleteCascadeRuleTests.cs new file mode 100644 index 00000000..01695120 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/ProductCatalog/ProductSoftDeleteCascadeRuleTests.cs @@ -0,0 +1,35 @@ +using ProductCatalog.Entities; +using ProductCatalog.SoftDelete; +using ProductCatalog.ValueObjects; +using Shouldly; +using Xunit; + +namespace APITemplate.Tests.Unit.ProductCatalog; + +public sealed class ProductSoftDeleteCascadeRuleTests +{ + private readonly ProductSoftDeleteCascadeRule _sut = new(); + + [Fact] + public void CanHandle_WhenProduct_ReturnsTrue() + { + Product product = new() + { + Id = Guid.NewGuid(), + Name = "P", + Description = "D", + Price = Price.FromPersistence(1m), + CategoryId = Guid.NewGuid(), + }; + + _sut.CanHandle(product).ShouldBeTrue(); + } + + [Fact] + public void CanHandle_WhenCategory_ReturnsFalse() + { + Category category = new() { Id = Guid.NewGuid(), Name = "C" }; + + _sut.CanHandle(category).ShouldBeFalse(); + } +} diff --git a/tests/APITemplate.Tests/Unit/Reviews/ProductReviewFilterValidatorTests.cs b/tests/APITemplate.Tests/Unit/Reviews/ProductReviewFilterValidatorTests.cs new file mode 100644 index 00000000..ad76caf0 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/Reviews/ProductReviewFilterValidatorTests.cs @@ -0,0 +1,74 @@ +using FluentValidation.Results; +using Reviews.Features; +using Shouldly; +using Xunit; + +namespace APITemplate.Tests.Unit.Reviews; + +public sealed class ProductReviewFilterValidatorTests +{ + private readonly ProductReviewFilterValidator _sut = new(); + + [Theory] + [InlineData(0, 5)] + [InlineData(6, 5)] + [InlineData(2, 1)] + public void Validate_WhenRatingRangeInvalid_Fails(int min, int max) + { + ProductReviewFilter filter = new(MinRating: min, MaxRating: max); + + ValidationResult result = _sut.Validate(filter); + + result.IsValid.ShouldBeFalse(); + } + + [Theory] + [InlineData(2, 4)] + [InlineData(5, 5)] + public void Validate_WhenRatingRangeValid_Passes(int min, int max) + { + ProductReviewFilter filter = new(MinRating: min, MaxRating: max); + + ValidationResult result = _sut.Validate(filter); + + result.IsValid.ShouldBeTrue(); + } + + [Theory] + [InlineData("not-a-field")] + [InlineData("productId")] + public void Validate_WhenSortByUnknown_Fails(string sortBy) + { + ProductReviewFilter filter = new(SortBy: sortBy, SortDirection: "asc"); + + ValidationResult result = _sut.Validate(filter); + + result.IsValid.ShouldBeFalse(); + } + + [Theory] + [InlineData("rating")] + [InlineData("Rating")] + [InlineData("createdAt")] + [InlineData("CREATEDAT")] + public void Validate_WhenSortByAllowed_Passes(string sortBy) + { + ProductReviewFilter filter = new(SortBy: sortBy, SortDirection: "desc"); + + ValidationResult result = _sut.Validate(filter); + + result.IsValid.ShouldBeTrue(); + } + + [Theory] + [InlineData("up")] + [InlineData("")] + public void Validate_WhenSortDirectionInvalid_Fails(string direction) + { + ProductReviewFilter filter = new(SortBy: "rating", SortDirection: direction); + + ValidationResult result = _sut.Validate(filter); + + result.IsValid.ShouldBeFalse(); + } +} diff --git a/tests/APITemplate.Tests/Unit/Reviews/RatingValueObjectTests.cs b/tests/APITemplate.Tests/Unit/Reviews/RatingValueObjectTests.cs new file mode 100644 index 00000000..67870fa1 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/Reviews/RatingValueObjectTests.cs @@ -0,0 +1,41 @@ +using ErrorOr; +using Reviews.Domain; +using Shouldly; +using Xunit; + +namespace APITemplate.Tests.Unit.Reviews; + +public sealed class RatingValueObjectTests +{ + [Theory] + [InlineData(0)] + [InlineData(-1)] + [InlineData(6)] + [InlineData(int.MinValue)] + public void Create_WhenOutOfRange_ReturnsError(int value) + { + ErrorOr result = Rating.Create(value); + + result.IsError.ShouldBeTrue(); + } + + [Theory] + [InlineData(1)] + [InlineData(3)] + [InlineData(5)] + public void Create_WhenInRange_ReturnsRating(int value) + { + ErrorOr result = Rating.Create(value); + + result.IsError.ShouldBeFalse(); + result.Value.Value.ShouldBe(value); + } + + [Fact] + public void FromPersistence_DoesNotValidateRange() + { + Rating r = Rating.FromPersistence(99); + + r.Value.ShouldBe(99); + } +} diff --git a/tests/APITemplate.Tests/Unit/StoredProcedures/StoredProcedureExecutorTests.cs b/tests/APITemplate.Tests/Unit/StoredProcedures/StoredProcedureExecutorTests.cs index f0e7aeac..b0928256 100644 --- a/tests/APITemplate.Tests/Unit/StoredProcedures/StoredProcedureExecutorTests.cs +++ b/tests/APITemplate.Tests/Unit/StoredProcedures/StoredProcedureExecutorTests.cs @@ -1,11 +1,5 @@ -using APITemplate.Domain.Interfaces; -using APITemplate.Infrastructure.Persistence; -using APITemplate.Infrastructure.Persistence.Auditing; -using APITemplate.Infrastructure.Persistence.EntityNormalization; -using APITemplate.Infrastructure.StoredProcedures; using Microsoft.EntityFrameworkCore; -using SharedKernel.Application.Context; -using SharedKernel.Infrastructure.SoftDelete; +using SharedKernel.Infrastructure.StoredProcedures; using Shouldly; using Xunit; @@ -16,42 +10,23 @@ public sealed class StoredProcedureExecutorTests [Fact] public async Task ExecuteAsync_WhenProviderDoesNotSupportRawSql_Throws() { - await using var dbContext = CreateDbContext(); - var sut = new StoredProcedureExecutor(dbContext); + await using TestEfContext dbContext = CreateDbContext(); + StoredProcedureExecutor sut = new(dbContext); await Should.ThrowAsync(() => sut.ExecuteAsync($"select 1", TestContext.Current.CancellationToken) ); } - private static AppDbContext CreateDbContext() + private static TestEfContext CreateDbContext() { - var options = new DbContextOptionsBuilder() + DbContextOptions options = new DbContextOptionsBuilder() .UseInMemoryDatabase(Guid.NewGuid().ToString()) .Options; - var stateManager = new AuditableEntityStateManager(); - - return new AppDbContext( - options, - new TestTenantProvider(), - new TestActorProvider(), - TimeProvider.System, - [], - new AppUserEntityNormalizationService(), - stateManager, - new SoftDeleteProcessor(stateManager) - ); + return new TestEfContext(options); } - private sealed class TestTenantProvider : ITenantProvider - { - public Guid TenantId => Guid.Empty; - public bool HasTenant => false; - } - - private sealed class TestActorProvider : IActorProvider - { - public Guid ActorId => Guid.Empty; - } + private sealed class TestEfContext(DbContextOptions options) + : DbContext(options); } diff --git a/tests/APITemplate.Tests/Unit/TestData/EmailTheoryData.cs b/tests/APITemplate.Tests/Unit/TestData/EmailTheoryData.cs new file mode 100644 index 00000000..d95a0f32 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/TestData/EmailTheoryData.cs @@ -0,0 +1,19 @@ +using Xunit; + +namespace APITemplate.Tests.Unit.TestData; + +/// Shared inputs for parametrized tests. +public static class EmailTheoryData +{ + public static IEnumerable InvalidRawInputs() + { + yield return [null]; + yield return [""]; + yield return [" "]; + yield return ["not-an-email"]; + yield return [$"{new string('a', 316)}@x.co"]; + } + + public static TheoryData TrimmingAndNormalizationCases => + new() { { " user@example.com ", "user@example.com" }, { "A@B.CO", "A@B.CO" } }; +} diff --git a/tests/APITemplate.Tests/Unit/TestData/PriceTheoryData.cs b/tests/APITemplate.Tests/Unit/TestData/PriceTheoryData.cs new file mode 100644 index 00000000..86b7e568 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/TestData/PriceTheoryData.cs @@ -0,0 +1,13 @@ +using Xunit; + +namespace APITemplate.Tests.Unit.TestData; + +/// Shared inputs for parametrized tests. +public static class PriceTheoryData +{ + public static TheoryData InvalidNegativeAmounts => + new() { -0.01m, -1m, decimal.MinValue }; + + public static TheoryData ValidNonNegativeAmounts => + new() { 0m, 0.01m, 1m, 999999999.99m }; +} diff --git a/tests/APITemplate.Tests/Unit/TestData/SsrfTheoryData.cs b/tests/APITemplate.Tests/Unit/TestData/SsrfTheoryData.cs new file mode 100644 index 00000000..ff6def9e --- /dev/null +++ b/tests/APITemplate.Tests/Unit/TestData/SsrfTheoryData.cs @@ -0,0 +1,23 @@ +using System.Net; + +namespace APITemplate.Tests.Unit.TestData; + +/// Private / link-local IPv4 strings for SSRF guard tests. +public static class SsrfTheoryData +{ + public static readonly string[] ProhibitedIpv4List = + [ + "127.0.0.1", + "10.0.0.1", + "172.16.0.1", + "172.31.255.255", + "192.168.1.1", + "169.254.1.1", + ]; + + public static IEnumerable PrivateIpv4Cases() => + ProhibitedIpv4List.Select(ip => new object[] { ip }); + + public static IEnumerable ProhibitedPrivateIpv4Addresses() => + ProhibitedIpv4List.Select(ip => new object[] { IPAddress.Parse(ip) }); +} diff --git a/tests/APITemplate.Tests/Unit/Validators/CreateProductRequestValidatorTests.cs b/tests/APITemplate.Tests/Unit/Validators/CreateProductRequestValidatorTests.cs index 0f15514d..79fbc2b5 100644 --- a/tests/APITemplate.Tests/Unit/Validators/CreateProductRequestValidatorTests.cs +++ b/tests/APITemplate.Tests/Unit/Validators/CreateProductRequestValidatorTests.cs @@ -1,3 +1,4 @@ +using APITemplate.Tests.Unit.TestData; using ProductCatalog.Features.Product.CreateProducts; using Shouldly; using Xunit; @@ -36,8 +37,10 @@ public void Annotation_NameExceeds200Characters_IsInvalid() } [Theory] - [InlineData(-1)] - [InlineData(-100.50)] + [MemberData( + nameof(PriceTheoryData.InvalidNegativeAmounts), + MemberType = typeof(PriceTheoryData) + )] public void Annotation_PriceNegative_IsInvalid(decimal price) { var request = new CreateProductRequest("Valid Name", null, price); diff --git a/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadSignerTests.cs b/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadSignerTests.cs index 89ac1456..d857d770 100644 --- a/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadSignerTests.cs +++ b/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadSignerTests.cs @@ -1,7 +1,7 @@ -using APITemplate.Infrastructure.Webhooks; using Microsoft.Extensions.Options; -using SharedKernel.Application.Options; using Shouldly; +using Webhooks.Contracts; +using Webhooks.Security; using Xunit; namespace APITemplate.Tests.Unit.Webhooks; diff --git a/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadValidatorTests.cs b/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadValidatorTests.cs index 9a9a74d6..496dbdc9 100644 --- a/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadValidatorTests.cs +++ b/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadValidatorTests.cs @@ -1,8 +1,8 @@ -using APITemplate.Infrastructure.Webhooks; -using APITemplate.Tests.Integration.Helpers; +using APITemplate.Tests.Unit.Helpers; using Microsoft.Extensions.Options; -using SharedKernel.Application.Options; using Shouldly; +using Webhooks.Contracts; +using Webhooks.Security; using Xunit; namespace APITemplate.Tests.Unit.Webhooks; @@ -45,6 +45,22 @@ public void IsValid_WrongHmac_ReturnsFalse() validator.IsValid(payload, "wrong-signature", timestamp).ShouldBeFalse(); } + [Theory] + [InlineData("", "sig", "1")] + [InlineData("{}", "", "1")] + [InlineData("{}", "sig", "")] + public void IsValid_WhenTimestampOrSignatureMissing_ReturnsFalse( + string payload, + string signature, + string timestamp + ) + { + var now = DateTimeOffset.UtcNow; + var validator = CreateValidator(new FakeTimeProvider(now)); + + validator.IsValid(payload, signature, timestamp).ShouldBeFalse(); + } + [Fact] public void IsValid_TimestampWithinTolerance_ReturnsTrue() { diff --git a/tests/APITemplate.Tests/Unit/Webhooks/OutgoingWebhookSsrfTests.cs b/tests/APITemplate.Tests/Unit/Webhooks/OutgoingWebhookSsrfTests.cs index b3169982..f72cd27a 100644 --- a/tests/APITemplate.Tests/Unit/Webhooks/OutgoingWebhookSsrfTests.cs +++ b/tests/APITemplate.Tests/Unit/Webhooks/OutgoingWebhookSsrfTests.cs @@ -1,5 +1,6 @@ using System.Net; using System.Reflection; +using APITemplate.Tests.Unit.TestData; using Shouldly; using Webhooks.Services; using Xunit; @@ -20,12 +21,7 @@ private static bool IsProhibited(IPAddress address) } [Theory] - [InlineData("127.0.0.1")] - [InlineData("10.0.0.1")] - [InlineData("172.16.0.1")] - [InlineData("172.31.255.255")] - [InlineData("192.168.1.1")] - [InlineData("169.254.1.1")] + [MemberData(nameof(SsrfTheoryData.PrivateIpv4Cases), MemberType = typeof(SsrfTheoryData))] public void IsProhibitedAddress_BlocksPrivateIPv4(string ip) { IsProhibited(IPAddress.Parse(ip)).ShouldBeTrue($"{ip} should be prohibited"); From 61ee8d31449488994d8f29eb1c2013252fc114d3 Mon Sep 17 00:00:00 2001 From: "tadeas.zribko" Date: Sat, 4 Apr 2026 14:48:12 +0200 Subject: [PATCH 2/8] feat: Enhance testing capabilities and refactor test helpers - Added InternalsVisibleTo attribute in Webhooks.csproj to allow access for APITemplate.Tests. - Refactored WebhookTestHelper to utilize HmacHelper for computing HMAC signatures. - Removed redundant WebhookTestHelper from Integration tests and updated references in Unit tests. - Improved validation logic in SseStreamRequestValidationTests by consolidating validation methods. - Updated various test files to enhance structure and maintainability. --- src/Modules/Webhooks/Webhooks.csproj | 3 +++ .../Features/WebhooksControllerTests.cs | 1 + .../Integration/Helpers/WebhookTestHelper.cs | 16 ----------- .../TenantAwareOutputCachePolicyTests.cs | 10 ++----- .../SseStreamRequestValidationTests.cs | 25 +++++++++-------- .../LocalFileStorageServiceTests.cs | 6 ++++- .../Helpers/TestClaimsPrincipalFactory.cs | 27 +++++++++++++++++++ .../Unit/Helpers/WebhookTestHelper.cs | 8 ++---- .../InMemoryIdempotencyStoreTests.cs | 15 ++++++----- .../Identity/TenantClaimValidatorTests.cs | 21 +++++---------- .../Identity/TenantCodeValueObjectTests.cs | 13 ++++++--- .../FluidEmailTemplateRendererTests.cs | 9 +++---- .../Unit/TestData/SsrfTheoryData.cs | 8 +++--- 13 files changed, 87 insertions(+), 75 deletions(-) delete mode 100644 tests/APITemplate.Tests/Integration/Helpers/WebhookTestHelper.cs create mode 100644 tests/APITemplate.Tests/Unit/Helpers/TestClaimsPrincipalFactory.cs diff --git a/src/Modules/Webhooks/Webhooks.csproj b/src/Modules/Webhooks/Webhooks.csproj index 63c7d36b..7f665bb5 100644 --- a/src/Modules/Webhooks/Webhooks.csproj +++ b/src/Modules/Webhooks/Webhooks.csproj @@ -4,6 +4,9 @@ enable enable + + + diff --git a/tests/APITemplate.Tests/Integration/Features/WebhooksControllerTests.cs b/tests/APITemplate.Tests/Integration/Features/WebhooksControllerTests.cs index e6d7eb90..0a8584be 100644 --- a/tests/APITemplate.Tests/Integration/Features/WebhooksControllerTests.cs +++ b/tests/APITemplate.Tests/Integration/Features/WebhooksControllerTests.cs @@ -2,6 +2,7 @@ using System.Text; using APITemplate.Application.Features.Examples.DTOs; using APITemplate.Tests.Integration.Helpers; +using APITemplate.Tests.Unit.Helpers; using Shouldly; using Xunit; diff --git a/tests/APITemplate.Tests/Integration/Helpers/WebhookTestHelper.cs b/tests/APITemplate.Tests/Integration/Helpers/WebhookTestHelper.cs deleted file mode 100644 index 27c2a3ca..00000000 --- a/tests/APITemplate.Tests/Integration/Helpers/WebhookTestHelper.cs +++ /dev/null @@ -1,16 +0,0 @@ -using System.Security.Cryptography; -using System.Text; - -namespace APITemplate.Tests.Integration.Helpers; - -internal static class WebhookTestHelper -{ - internal static string ComputeHmacSignature(string body, string timestamp, string secret) - { - var signedContent = $"{timestamp}.{body}"; - var keyBytes = Encoding.UTF8.GetBytes(secret); - var contentBytes = Encoding.UTF8.GetBytes(signedContent); - var hashBytes = HMACSHA256.HashData(keyBytes, contentBytes); - return Convert.ToHexStringLower(hashBytes); - } -} diff --git a/tests/APITemplate.Tests/Unit/Cache/TenantAwareOutputCachePolicyTests.cs b/tests/APITemplate.Tests/Unit/Cache/TenantAwareOutputCachePolicyTests.cs index 8ea19850..4aa85a27 100644 --- a/tests/APITemplate.Tests/Unit/Cache/TenantAwareOutputCachePolicyTests.cs +++ b/tests/APITemplate.Tests/Unit/Cache/TenantAwareOutputCachePolicyTests.cs @@ -1,6 +1,5 @@ -using System.Security.Claims; using APITemplate.Api.Cache; -using Identity.Security; +using APITemplate.Tests.Unit.Helpers; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.OutputCaching; using Shouldly; @@ -64,12 +63,7 @@ private static OutputCacheContext CreateContext(string method = "GET") var httpContext = new DefaultHttpContext(); httpContext.Request.Method = method; httpContext.Request.Path = "/api/v1/products"; - httpContext.User = new ClaimsPrincipal( - new ClaimsIdentity( - [new Claim(AuthConstants.Claims.TenantId, Guid.NewGuid().ToString())], - authenticationType: "Test" - ) - ); + httpContext.User = TestClaimsPrincipalFactory.WithTenantId(Guid.NewGuid().ToString()); return new OutputCacheContext { HttpContext = httpContext }; } } diff --git a/tests/APITemplate.Tests/Unit/Chatting/SseStreamRequestValidationTests.cs b/tests/APITemplate.Tests/Unit/Chatting/SseStreamRequestValidationTests.cs index a1eac2e7..66628ea9 100644 --- a/tests/APITemplate.Tests/Unit/Chatting/SseStreamRequestValidationTests.cs +++ b/tests/APITemplate.Tests/Unit/Chatting/SseStreamRequestValidationTests.cs @@ -14,13 +14,7 @@ public sealed class SseStreamRequestValidationTests public void Validation_WhenCountOutOfRange_Fails(int count) { SseStreamRequest request = new() { Count = count }; - var results = new List(); - bool valid = Validator.TryValidateObject( - request, - new ValidationContext(request), - results, - validateAllProperties: true - ); + bool valid = TryValidateAll(request, out List results); valid.ShouldBeFalse(); results.ShouldNotBeEmpty(); @@ -33,14 +27,19 @@ public void Validation_WhenCountOutOfRange_Fails(int count) public void Validation_WhenCountInRange_Passes(int count) { SseStreamRequest request = new() { Count = count }; - var results = new List(); - bool valid = Validator.TryValidateObject( - request, - new ValidationContext(request), + bool valid = TryValidateAll(request, out _); + + valid.ShouldBeTrue(); + } + + private static bool TryValidateAll(object instance, out List results) + { + results = []; + return Validator.TryValidateObject( + instance, + new ValidationContext(instance), results, validateAllProperties: true ); - - valid.ShouldBeTrue(); } } diff --git a/tests/APITemplate.Tests/Unit/FileStorage/LocalFileStorageServiceTests.cs b/tests/APITemplate.Tests/Unit/FileStorage/LocalFileStorageServiceTests.cs index bffc385e..35aba4f9 100644 --- a/tests/APITemplate.Tests/Unit/FileStorage/LocalFileStorageServiceTests.cs +++ b/tests/APITemplate.Tests/Unit/FileStorage/LocalFileStorageServiceTests.cs @@ -16,6 +16,11 @@ public sealed class LocalFileStorageServiceTests : IDisposable ); private bool _disposed; + public LocalFileStorageServiceTests() + { + Directory.CreateDirectory(_basePath); + } + public void Dispose() { if (_disposed) @@ -87,7 +92,6 @@ private static ITenantProvider CreateTenant() private LocalFileStorageService CreateSut(ITenantProvider tenant) { - Directory.CreateDirectory(_basePath); IOptions options = Options.Create( new FileStorageOptions { BasePath = _basePath } ); diff --git a/tests/APITemplate.Tests/Unit/Helpers/TestClaimsPrincipalFactory.cs b/tests/APITemplate.Tests/Unit/Helpers/TestClaimsPrincipalFactory.cs new file mode 100644 index 00000000..bfa093bb --- /dev/null +++ b/tests/APITemplate.Tests/Unit/Helpers/TestClaimsPrincipalFactory.cs @@ -0,0 +1,27 @@ +using System.Security.Claims; +using Identity.Security; + +namespace APITemplate.Tests.Unit.Helpers; + +internal static class TestClaimsPrincipalFactory +{ + internal const string TestAuthenticationType = "Test"; + + internal static ClaimsPrincipal WithTenantId(string tenantId) => + new( + new ClaimsIdentity( + [new Claim(AuthConstants.Claims.TenantId, tenantId)], + authenticationType: TestAuthenticationType + ) + ); + + internal static ClaimsPrincipal WithOptionalTenantClaim(string? tenantValue) + { + Claim[] claims = tenantValue is null + ? [] + : [new Claim(AuthConstants.Claims.TenantId, tenantValue)]; + return new ClaimsPrincipal( + new ClaimsIdentity(claims, authenticationType: TestAuthenticationType) + ); + } +} diff --git a/tests/APITemplate.Tests/Unit/Helpers/WebhookTestHelper.cs b/tests/APITemplate.Tests/Unit/Helpers/WebhookTestHelper.cs index 4b6a22fd..b62970b9 100644 --- a/tests/APITemplate.Tests/Unit/Helpers/WebhookTestHelper.cs +++ b/tests/APITemplate.Tests/Unit/Helpers/WebhookTestHelper.cs @@ -1,5 +1,4 @@ -using System.Security.Cryptography; -using System.Text; +using Webhooks.Security; namespace APITemplate.Tests.Unit.Helpers; @@ -7,10 +6,7 @@ internal static class WebhookTestHelper { internal static string ComputeHmacSignature(string body, string timestamp, string secret) { - string signedContent = $"{timestamp}.{body}"; - byte[] keyBytes = Encoding.UTF8.GetBytes(secret); - byte[] contentBytes = Encoding.UTF8.GetBytes(signedContent); - byte[] hashBytes = HMACSHA256.HashData(keyBytes, contentBytes); + byte[] hashBytes = HmacHelper.ComputeHash(HmacHelper.GetKeyBytes(secret), timestamp, body); return Convert.ToHexStringLower(hashBytes); } } diff --git a/tests/APITemplate.Tests/Unit/Idempotency/InMemoryIdempotencyStoreTests.cs b/tests/APITemplate.Tests/Unit/Idempotency/InMemoryIdempotencyStoreTests.cs index 5fa71ac5..74575363 100644 --- a/tests/APITemplate.Tests/Unit/Idempotency/InMemoryIdempotencyStoreTests.cs +++ b/tests/APITemplate.Tests/Unit/Idempotency/InMemoryIdempotencyStoreTests.cs @@ -19,9 +19,7 @@ public sealed class InMemoryIdempotencyStoreTests [InlineData("")] public async Task TryAcquireAsync_WhenKeyIsNew_ReturnsLockToken(string key) { - CancellationToken ct = TestContext.Current.CancellationToken; - FakeTimeProvider time = new(DateTimeOffset.UtcNow); - InMemoryIdempotencyStore store = new(time); + (InMemoryIdempotencyStore store, CancellationToken ct) = CreateStore(); string? token = await store.TryAcquireAsync(key, DefaultTtl, ct); @@ -34,9 +32,7 @@ public async Task TryAcquireAsync_WhenKeyIsNew_ReturnsLockToken(string key) [InlineData(3600000)] public async Task TryAcquireAsync_VariousTtlsStillAcquires(int ttlMs) { - CancellationToken ct = TestContext.Current.CancellationToken; - FakeTimeProvider time = new(DateTimeOffset.UtcNow); - InMemoryIdempotencyStore store = new(time); + (InMemoryIdempotencyStore store, CancellationToken ct) = CreateStore(); string key = Guid.NewGuid().ToString("N"); string? token = await store.TryAcquireAsync(key, TimeSpan.FromMilliseconds(ttlMs), ct); @@ -170,6 +166,13 @@ public async Task TryGetAsync_WhenExpired_ReturnsNull() cached.ShouldBeNull(); } + private static (InMemoryIdempotencyStore Store, CancellationToken Ct) CreateStore() + { + CancellationToken ct = TestContext.Current.CancellationToken; + FakeTimeProvider time = new(DateTimeOffset.UtcNow); + return (new InMemoryIdempotencyStore(time), ct); + } + private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider { private DateTimeOffset _utcNow = utcNow; diff --git a/tests/APITemplate.Tests/Unit/Identity/TenantClaimValidatorTests.cs b/tests/APITemplate.Tests/Unit/Identity/TenantClaimValidatorTests.cs index 014dc564..9000331c 100644 --- a/tests/APITemplate.Tests/Unit/Identity/TenantClaimValidatorTests.cs +++ b/tests/APITemplate.Tests/Unit/Identity/TenantClaimValidatorTests.cs @@ -1,4 +1,4 @@ -using System.Security.Claims; +using APITemplate.Tests.Unit.Helpers; using Identity.Security; using Shouldly; using Xunit; @@ -13,26 +13,19 @@ public sealed class TenantClaimValidatorTests [InlineData("")] public void HasValidTenantClaim_WhenMissingOrInvalid_ReturnsFalse(string? tenantValue) { - Claim[] claims = tenantValue is null - ? [] - : [new Claim(AuthConstants.Claims.TenantId, tenantValue)]; - ClaimsPrincipal principal = new(new ClaimsIdentity(claims, authenticationType: "Test")); - - TenantClaimValidator.HasValidTenantClaim(principal).ShouldBeFalse(); + TenantClaimValidator + .HasValidTenantClaim(TestClaimsPrincipalFactory.WithOptionalTenantClaim(tenantValue)) + .ShouldBeFalse(); } [Fact] public void HasValidTenantClaim_WhenNonEmptyGuid_ReturnsTrue() { string tenantId = Guid.NewGuid().ToString(); - ClaimsPrincipal principal = new( - new ClaimsIdentity( - [new Claim(AuthConstants.Claims.TenantId, tenantId)], - authenticationType: "Test" - ) - ); - TenantClaimValidator.HasValidTenantClaim(principal).ShouldBeTrue(); + TenantClaimValidator + .HasValidTenantClaim(TestClaimsPrincipalFactory.WithTenantId(tenantId)) + .ShouldBeTrue(); } [Fact] diff --git a/tests/APITemplate.Tests/Unit/Identity/TenantCodeValueObjectTests.cs b/tests/APITemplate.Tests/Unit/Identity/TenantCodeValueObjectTests.cs index 10d7470b..5175a49e 100644 --- a/tests/APITemplate.Tests/Unit/Identity/TenantCodeValueObjectTests.cs +++ b/tests/APITemplate.Tests/Unit/Identity/TenantCodeValueObjectTests.cs @@ -7,13 +7,20 @@ namespace APITemplate.Tests.Unit.Identity; public sealed class TenantCodeValueObjectTests { + [Fact] + public void Create_WhenNull_ReturnsError() + { + ErrorOr result = TenantCode.Create(null!); + + result.IsError.ShouldBeTrue(); + } + [Theory] - [InlineData(null)] [InlineData("")] [InlineData(" ")] - public void Create_WhenEmpty_ReturnsError(string? raw) + public void Create_WhenEmptyOrWhitespace_ReturnsError(string raw) { - ErrorOr result = TenantCode.Create(raw!); + ErrorOr result = TenantCode.Create(raw); result.IsError.ShouldBeTrue(); } diff --git a/tests/APITemplate.Tests/Unit/Notifications/FluidEmailTemplateRendererTests.cs b/tests/APITemplate.Tests/Unit/Notifications/FluidEmailTemplateRendererTests.cs index 1bc6c807..2517f634 100644 --- a/tests/APITemplate.Tests/Unit/Notifications/FluidEmailTemplateRendererTests.cs +++ b/tests/APITemplate.Tests/Unit/Notifications/FluidEmailTemplateRendererTests.cs @@ -7,6 +7,9 @@ namespace APITemplate.Tests.Unit.Notifications; public sealed class FluidEmailTemplateRendererTests { + private static readonly string UnknownTemplateId = + $"{EmailTemplateNames.UserRegistration}.fixture-unknown-template"; + private readonly FluidEmailTemplateRenderer _sut = new(); [Fact] @@ -34,11 +37,7 @@ public async Task RenderAsync_UserRegistration_SubstitutesModel() public async Task RenderAsync_UnknownTemplate_Throws() { await Should.ThrowAsync(() => - _sut.RenderAsync( - "Does.Not.Exist.Template", - new { }, - TestContext.Current.CancellationToken - ) + _sut.RenderAsync(UnknownTemplateId, new { }, TestContext.Current.CancellationToken) ); } } diff --git a/tests/APITemplate.Tests/Unit/TestData/SsrfTheoryData.cs b/tests/APITemplate.Tests/Unit/TestData/SsrfTheoryData.cs index ff6def9e..20bf01d2 100644 --- a/tests/APITemplate.Tests/Unit/TestData/SsrfTheoryData.cs +++ b/tests/APITemplate.Tests/Unit/TestData/SsrfTheoryData.cs @@ -15,9 +15,11 @@ public static class SsrfTheoryData "169.254.1.1", ]; - public static IEnumerable PrivateIpv4Cases() => - ProhibitedIpv4List.Select(ip => new object[] { ip }); + public static IEnumerable PrivateIpv4Cases() => MapProhibitedIpv4(ip => ip); public static IEnumerable ProhibitedPrivateIpv4Addresses() => - ProhibitedIpv4List.Select(ip => new object[] { IPAddress.Parse(ip) }); + MapProhibitedIpv4(IPAddress.Parse); + + private static IEnumerable MapProhibitedIpv4(Func map) => + ProhibitedIpv4List.Select(ip => new object[] { map(ip) }); } From 04f515fd239b388dc98f22f003da2b947942b605 Mon Sep 17 00:00:00 2001 From: "tadeas.zribko" Date: Sat, 4 Apr 2026 14:52:35 +0200 Subject: [PATCH 3/8] refactor: Remove unused AssemblyFixtures and enhance type constraints in SsrfTheoryData - Deleted the unused AssemblyFixtures class from the test project. - Updated the MapProhibitedIpv4 method in SsrfTheoryData to include a type constraint for better type safety. --- tests/APITemplate.Tests/AssemblyFixtures.cs | 3 --- tests/APITemplate.Tests/Unit/TestData/SsrfTheoryData.cs | 4 ++-- 2 files changed, 2 insertions(+), 5 deletions(-) delete mode 100644 tests/APITemplate.Tests/AssemblyFixtures.cs diff --git a/tests/APITemplate.Tests/AssemblyFixtures.cs b/tests/APITemplate.Tests/AssemblyFixtures.cs deleted file mode 100644 index d4da9b82..00000000 --- a/tests/APITemplate.Tests/AssemblyFixtures.cs +++ /dev/null @@ -1,3 +0,0 @@ -namespace APITemplate.Tests; - -file static class AssemblyFixtures { } diff --git a/tests/APITemplate.Tests/Unit/TestData/SsrfTheoryData.cs b/tests/APITemplate.Tests/Unit/TestData/SsrfTheoryData.cs index 20bf01d2..7750cbac 100644 --- a/tests/APITemplate.Tests/Unit/TestData/SsrfTheoryData.cs +++ b/tests/APITemplate.Tests/Unit/TestData/SsrfTheoryData.cs @@ -20,6 +20,6 @@ public static class SsrfTheoryData public static IEnumerable ProhibitedPrivateIpv4Addresses() => MapProhibitedIpv4(IPAddress.Parse); - private static IEnumerable MapProhibitedIpv4(Func map) => - ProhibitedIpv4List.Select(ip => new object[] { map(ip) }); + private static IEnumerable MapProhibitedIpv4(Func map) + where T : notnull => ProhibitedIpv4List.Select(ip => new object[] { map(ip) }); } From 88e6b7e16f6c99ea3ddf65fa3c8c8da7413d2b87 Mon Sep 17 00:00:00 2001 From: "tadeas.zribko" Date: Sat, 4 Apr 2026 14:55:42 +0200 Subject: [PATCH 4/8] fix: Update EmailValueObjectTests to handle null inputs correctly - Modified the Create method call in EmailValueObjectTests to use the null-forgiving operator, ensuring proper handling of null inputs during test execution. --- tests/APITemplate.Tests/Unit/Identity/EmailValueObjectTests.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/APITemplate.Tests/Unit/Identity/EmailValueObjectTests.cs b/tests/APITemplate.Tests/Unit/Identity/EmailValueObjectTests.cs index 9771bfe1..f49631f6 100644 --- a/tests/APITemplate.Tests/Unit/Identity/EmailValueObjectTests.cs +++ b/tests/APITemplate.Tests/Unit/Identity/EmailValueObjectTests.cs @@ -12,7 +12,7 @@ public sealed class EmailValueObjectTests [MemberData(nameof(EmailTheoryData.InvalidRawInputs), MemberType = typeof(EmailTheoryData))] public void Create_WhenInvalid_ReturnsError(string? raw) { - ErrorOr result = Email.Create(raw ?? string.Empty); + ErrorOr result = Email.Create(raw!); result.IsError.ShouldBeTrue(); } From c0fd1bd9ace0c6a51b12332b2e2eec3dfc065d47 Mon Sep 17 00:00:00 2001 From: "tadeas.zribko" Date: Sat, 4 Apr 2026 15:00:43 +0200 Subject: [PATCH 5/8] refactor: Update test project configurations and improve documentation - Enhanced comments in APITemplate.Tests.csproj to clarify the exclusion of integration tests and migration plans. - Updated summary in SsrfTheoryData to provide clearer context on prohibited local IPv4 strings for SSRF guard tests. - Renamed test method in HmacWebhookPayloadValidatorTests for improved clarity on its purpose regarding payload validation. --- tests/APITemplate.Tests/APITemplate.Tests.csproj | 7 ++++++- tests/APITemplate.Tests/Unit/TestData/SsrfTheoryData.cs | 2 +- .../Unit/Webhooks/HmacWebhookPayloadValidatorTests.cs | 2 +- 3 files changed, 8 insertions(+), 3 deletions(-) diff --git a/tests/APITemplate.Tests/APITemplate.Tests.csproj b/tests/APITemplate.Tests/APITemplate.Tests.csproj index 168d7ac2..fd01db9c 100644 --- a/tests/APITemplate.Tests/APITemplate.Tests.csproj +++ b/tests/APITemplate.Tests/APITemplate.Tests.csproj @@ -26,7 +26,12 @@ - + diff --git a/tests/APITemplate.Tests/Unit/TestData/SsrfTheoryData.cs b/tests/APITemplate.Tests/Unit/TestData/SsrfTheoryData.cs index 7750cbac..70645df8 100644 --- a/tests/APITemplate.Tests/Unit/TestData/SsrfTheoryData.cs +++ b/tests/APITemplate.Tests/Unit/TestData/SsrfTheoryData.cs @@ -2,7 +2,7 @@ namespace APITemplate.Tests.Unit.TestData; -/// Private / link-local IPv4 strings for SSRF guard tests. +/// Prohibited local IPv4 strings (loopback, private, and link-local) for SSRF guard tests. public static class SsrfTheoryData { public static readonly string[] ProhibitedIpv4List = diff --git a/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadValidatorTests.cs b/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadValidatorTests.cs index 496dbdc9..7dadfadc 100644 --- a/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadValidatorTests.cs +++ b/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadValidatorTests.cs @@ -49,7 +49,7 @@ public void IsValid_WrongHmac_ReturnsFalse() [InlineData("", "sig", "1")] [InlineData("{}", "", "1")] [InlineData("{}", "sig", "")] - public void IsValid_WhenTimestampOrSignatureMissing_ReturnsFalse( + public void IsValid_WhenPayloadEmptyOrSignatureOrTimestampMissing_ReturnsFalse( string payload, string signature, string timestamp From 72f902419feaa9331e61e54aed98504ba81b6e38 Mon Sep 17 00:00:00 2001 From: "tadeas.zribko" Date: Sat, 4 Apr 2026 15:32:43 +0200 Subject: [PATCH 6/8] refactor: Update test project configurations and enhance integration tests - Improved the configuration of APITemplate.Tests.csproj to include new package references for JasperFx and Testcontainers. - Refactored CustomWebApplicationFactory to utilize PostgreSQL and MongoDB containers for integration tests. - Enhanced IntegrationAuthHelper to support new identity structures and improved token generation. - Updated WebhooksControllerTests to ensure proper client initialization and added integration traits for better categorization. - Cleaned up TestServiceHelper by removing obsolete methods and improving service registration logic. --- Directory.Packages.props | 165 +++++++++--------- .../APITemplate.Tests.csproj | 106 ++++++----- .../CustomWebApplicationFactory.cs | 90 ++++++---- .../Features/WebhooksControllerTests.cs | 5 +- .../Integration/Helpers/TestServiceHelper.cs | 92 +++------- .../Integration/IntegrationAuthHelper.cs | 40 +++-- 6 files changed, 250 insertions(+), 248 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 7f55b53e..08f4473f 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -3,87 +3,88 @@ true - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + - + \ No newline at end of file diff --git a/tests/APITemplate.Tests/APITemplate.Tests.csproj b/tests/APITemplate.Tests/APITemplate.Tests.csproj index fd01db9c..416839f8 100644 --- a/tests/APITemplate.Tests/APITemplate.Tests.csproj +++ b/tests/APITemplate.Tests/APITemplate.Tests.csproj @@ -4,67 +4,77 @@ enable enable false + + Category!=Integration.Docker - - - - - - - + + + + + + + + all runtime; build; native; contentfiles; analyzers; buildtransitive - - - - - + + + + + + + - + - - - - - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - + + + + + + + + + + diff --git a/tests/APITemplate.Tests/Integration/CustomWebApplicationFactory.cs b/tests/APITemplate.Tests/Integration/CustomWebApplicationFactory.cs index 8ee77cb1..c07f533e 100644 --- a/tests/APITemplate.Tests/Integration/CustomWebApplicationFactory.cs +++ b/tests/APITemplate.Tests/Integration/CustomWebApplicationFactory.cs @@ -1,70 +1,86 @@ -using APITemplate.Infrastructure.Persistence; using APITemplate.Tests.Integration.Helpers; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Diagnostics; using Microsoft.Extensions.Configuration; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.DependencyInjection.Extensions; +using Testcontainers.MongoDb; +using Testcontainers.PostgreSql; using Xunit; namespace APITemplate.Tests.Integration; -public class CustomWebApplicationFactory : WebApplicationFactory, IAsyncLifetime +/// +/// Hosts the modular API with real PostgreSQL and MongoDB via Testcontainers. Requires a local Docker engine. +/// Containers are created in so fixture construction does not probe Docker. +/// +public sealed class CustomWebApplicationFactory : WebApplicationFactory, IAsyncLifetime { - private readonly string _dbName = Guid.NewGuid().ToString(); + private PostgreSqlContainer? _postgres; + private MongoDbContainer? _mongo; - // Pre-warm the server before parallel test classes start calling CreateClient(). - // Without this, concurrent constructors race to call StartServer(), causing duplicate - // InMemory DB seed operations ("An item with the same key has already been added"). - public ValueTask InitializeAsync() + public async ValueTask InitializeAsync() { + _postgres = new PostgreSqlBuilder("postgres:16-alpine") + .WithUsername("postgres") + .WithPassword("postgres") + .WithCleanUp(true) + .Build(); + + _mongo = new MongoDbBuilder("mongo:7").WithCleanUp(true).Build(); + + await Task.WhenAll(_postgres.StartAsync(), _mongo.StartAsync()); + + // Pre-warm the host before parallel test classes race on StartServer(). _ = Server; - return ValueTask.CompletedTask; } - // DisposeAsync() is satisfied by WebApplicationFactory's IAsyncDisposable implementation. + public new async ValueTask DisposeAsync() + { + await base.DisposeAsync(); + if (_mongo is not null) + await _mongo.DisposeAsync(); + if (_postgres is not null) + await _postgres.DisposeAsync(); + } protected override void ConfigureWebHost(IWebHostBuilder builder) { + PostgreSqlContainer postgres = + _postgres + ?? throw new InvalidOperationException( + "PostgreSQL container was not started; IAsyncLifetime.InitializeAsync must run first." + ); + MongoDbContainer mongo = + _mongo + ?? throw new InvalidOperationException( + "MongoDB container was not started; IAsyncLifetime.InitializeAsync must run first." + ); + + string pg = postgres.GetConnectionString(); + string mongoConnectionString = mongo.GetConnectionString(); + + // Host settings are merged so WebApplication.CreateBuilder sees these before reading + // ConnectionStrings:DefaultConnection (see Program.cs) — in-memory config alone can lose to appsettings. + builder.UseSetting("ConnectionStrings:DefaultConnection", pg); + builder.UseSetting("MongoDB:ConnectionString", mongoConnectionString); + builder.UseSetting("MongoDB:DatabaseName", "apitemplate_integration"); + builder.ConfigureAppConfiguration( (_, configBuilder) => { - var config = TestConfigurationHelper.GetBaseConfiguration(); + Dictionary config = TestConfigurationHelper.GetBaseConfiguration(); + config.Remove("ConnectionStrings:DefaultConnection"); + config.Remove("MongoDB:ConnectionString"); + config.Remove("MongoDB:DatabaseName"); configBuilder.AddInMemoryCollection(config); } ); builder.ConfigureTestServices(services => { - services.RemoveAll(typeof(DbContextOptions)); - services.RemoveAll(typeof(AppDbContext)); - - var optionsConfigs = services - .Where(d => - d.ServiceType.IsGenericType - && d.ServiceType.GetGenericTypeDefinition() - .FullName?.Contains("IDbContextOptionsConfiguration") == true - ) - .ToList(); - - foreach (var d in optionsConfigs) - services.Remove(d); - - services.AddDbContext(options => - options - .UseInMemoryDatabase(_dbName) - .ConfigureWarnings(w => w.Ignore(InMemoryEventId.TransactionIgnoredWarning)) - ); - - TestServiceHelper.MockMongoServices(services); TestServiceHelper.RemoveExternalHealthChecks(services); - TestServiceHelper.ReplaceProductRepositoryWithInMemory(services); TestServiceHelper.ReplaceOutputCacheWithInMemory(services); TestServiceHelper.ReplaceDataProtectionWithInMemory(services); - TestServiceHelper.ReplaceTicketStoreWithInMemory(services); TestServiceHelper.ConfigureTestAuthentication(services); TestServiceHelper.RemoveTickerQRuntimeServices(services); TestServiceHelper.ReplaceStartupCoordinationWithNoOp(services); diff --git a/tests/APITemplate.Tests/Integration/Features/WebhooksControllerTests.cs b/tests/APITemplate.Tests/Integration/Features/WebhooksControllerTests.cs index 0a8584be..12a3e641 100644 --- a/tests/APITemplate.Tests/Integration/Features/WebhooksControllerTests.cs +++ b/tests/APITemplate.Tests/Integration/Features/WebhooksControllerTests.cs @@ -1,14 +1,15 @@ using System.Net; using System.Text; -using APITemplate.Application.Features.Examples.DTOs; using APITemplate.Tests.Integration.Helpers; using APITemplate.Tests.Unit.Helpers; using Shouldly; +using Webhooks.Contracts; using Xunit; namespace APITemplate.Tests.Integration.Features; -public class WebhooksControllerTests : IClassFixture +[Trait("Category", "Integration.Docker")] +public sealed class WebhooksControllerTests : IClassFixture { private readonly HttpClient _client; diff --git a/tests/APITemplate.Tests/Integration/Helpers/TestServiceHelper.cs b/tests/APITemplate.Tests/Integration/Helpers/TestServiceHelper.cs index 589a99d3..19bec4b2 100644 --- a/tests/APITemplate.Tests/Integration/Helpers/TestServiceHelper.cs +++ b/tests/APITemplate.Tests/Integration/Helpers/TestServiceHelper.cs @@ -1,24 +1,18 @@ -using APITemplate.Application.Common.Security; -using APITemplate.Domain.Interfaces; -using APITemplate.Infrastructure.BackgroundJobs.TickerQ; -using APITemplate.Infrastructure.BackgroundJobs.TickerQ.Coordination; -using APITemplate.Infrastructure.Health; -using APITemplate.Infrastructure.Persistence; -using APITemplate.Infrastructure.Persistence.Startup; -using APITemplate.Infrastructure.Security; -using APITemplate.Tests.Helpers; +using BackgroundJobs.TickerQ; +using Identity.Security; using Microsoft.AspNetCore.Authentication.JwtBearer; using Microsoft.AspNetCore.Authentication.OpenIdConnect; using Microsoft.AspNetCore.DataProtection; using Microsoft.AspNetCore.OutputCaching; -using Microsoft.Extensions.Caching.Distributed; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Microsoft.Extensions.Options; using Microsoft.IdentityModel.Protocols.OpenIdConnect; using Microsoft.IdentityModel.Tokens; -using Moq; +using SharedKernel.Application.BackgroundJobs; +using SharedKernel.Application.Options.Infrastructure; using SharedKernel.Application.Startup; +using SharedKernel.Infrastructure.Health; using StackExchange.Redis; namespace APITemplate.Tests.Integration.Helpers; @@ -74,16 +68,19 @@ internal static void RemoveExternalHealthChecks(IServiceCollection services) services.Configure( options => { - var toRemove = options - .Registrations.Where(r => - r.Name - is HealthCheckNames.MongoDb - or HealthCheckNames.Keycloak - or HealthCheckNames.PostgreSql - or HealthCheckNames.Dragonfly - ) - .ToList(); - foreach (var r in toRemove) + List toRemove = + options + .Registrations.Where(r => + r.Name + is HealthCheckNames.MongoDb + or HealthCheckNames.Keycloak + or HealthCheckNames.PostgreSql + or HealthCheckNames.Dragonfly + ) + .ToList(); + foreach ( + Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckRegistration r in toRemove + ) options.Registrations.Remove(r); } ); @@ -91,8 +88,6 @@ or HealthCheckNames.Dragonfly internal static void ReplaceOutputCacheWithInMemory(IServiceCollection services) { - // Remove DragonFly-backed cache services so tests use in-memory implementations - // and observability startup does not try to connect to a real Redis instance. services.RemoveAll(); services.RemoveAll(); services.AddOutputCache(); @@ -104,51 +99,27 @@ internal static void ReplaceOutputCacheWithInMemory(IServiceCollection services) internal static void ReplaceDataProtectionWithInMemory(IServiceCollection services) { - // Replace DragonFly-backed DataProtection with EphemeralDataProtectionProvider (no key persistence). services.RemoveAll(); services.AddSingleton(); } - internal static void ReplaceTicketStoreWithInMemory(IServiceCollection services) - { - // Replace Redis-backed IDistributedCache with in-memory so DragonflyTicketStore - // works without a real DragonFly instance in tests. - services.RemoveAll(); - services.AddDistributedMemoryCache(); - services.RemoveAll(); - services.AddSingleton(); - } - - internal static void MockMongoServices(IServiceCollection services) - { - services.RemoveAll(typeof(MongoDbContext)); - services.RemoveAll(typeof(IProductDataRepository)); - var mock = new Mock(); - services.AddSingleton(mock); - services.AddSingleton(mock.Object); - } - - internal static void ReplaceProductRepositoryWithInMemory(IServiceCollection services) - { - services.RemoveAll(typeof(IProductRepository)); - services.AddScoped(); - } - internal static void RemoveTickerQRuntimeServices(IServiceCollection services) { - var runtimeDescriptors = services.Where(IsTickerQRuntimeDescriptor).ToList(); - foreach (var descriptor in runtimeDescriptors) - { + List runtimeDescriptors = services + .Where(IsTickerQRuntimeDescriptor) + .ToList(); + foreach (ServiceDescriptor descriptor in runtimeDescriptors) services.Remove(descriptor); - } } internal static void ReplaceStartupCoordinationWithNoOp(IServiceCollection services) { services.RemoveAll(); - services.RemoveAll(); - services.AddScoped(); - services.AddScoped(); + services.RemoveAll(); + services.AddScoped(); + services.AddScoped(sp => + sp.GetRequiredService() + ); } private static bool IsTickerQRuntimeDescriptor(ServiceDescriptor descriptor) => @@ -160,8 +131,7 @@ private static bool IsTickerQRuntimeDescriptor(ServiceDescriptor descriptor) => || descriptor.ServiceType == typeof(TickerQRecurringJobRegistrar) || descriptor.ServiceType == typeof(IDistributedJobCoordinator) || ( - descriptor.ServiceType - == typeof(Application.Common.BackgroundJobs.IRecurringBackgroundJobRegistration) + descriptor.ServiceType == typeof(IRecurringBackgroundJobRegistration) && IsTickerQRuntimeType(descriptor.ImplementationType) ); @@ -178,22 +148,16 @@ implementationInstance is not null private static bool IsTickerQRuntimeType(Type? type) { if (type is null) - { return false; - } if ( IsTickerQRuntimeNamespace(type.Namespace) || IsTickerQRuntimeAssembly(type.Assembly.GetName().Name) ) - { return true; - } if (!type.IsGenericType) - { return false; - } return type.GetGenericArguments().Any(IsTickerQRuntimeType); } diff --git a/tests/APITemplate.Tests/Integration/IntegrationAuthHelper.cs b/tests/APITemplate.Tests/Integration/IntegrationAuthHelper.cs index 0d3c6712..e750a742 100644 --- a/tests/APITemplate.Tests/Integration/IntegrationAuthHelper.cs +++ b/tests/APITemplate.Tests/Integration/IntegrationAuthHelper.cs @@ -2,10 +2,11 @@ using System.Net.Http.Headers; using System.Security.Claims; using System.Security.Cryptography; -using APITemplate.Application.Common.Security; -using APITemplate.Domain.Entities; -using APITemplate.Domain.Enums; -using APITemplate.Infrastructure.Persistence; +using Identity.Entities; +using Identity.Enums; +using Identity.Persistence; +using Identity.Security; +using Identity.ValueObjects; using Microsoft.Extensions.DependencyInjection; using Microsoft.IdentityModel.Tokens; @@ -17,7 +18,7 @@ internal static class IntegrationAuthHelper internal static readonly RsaSecurityKey SecurityKey = new(RsaKey); - private static readonly SigningCredentials SigningCredentials = new( + private static readonly SigningCredentials _signingCredentials = new( SecurityKey, SecurityAlgorithms.RsaSha256 ); @@ -46,7 +47,7 @@ public static string CreateTestToken( audience: "api-template", claims: claims, expires: DateTime.UtcNow.AddHours(1), - signingCredentials: SigningCredentials + signingCredentials: _signingCredentials ); return new JwtSecurityTokenHandler().WriteToken(token); @@ -60,7 +61,7 @@ public static void Authenticate( UserRole role = UserRole.PlatformAdmin ) { - var token = CreateTestToken(userId, tenantId, username, role); + string token = CreateTestToken(userId, tenantId, username, role); client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); } @@ -89,6 +90,9 @@ public static void AuthenticateAsTenantAdmin( ) => Authenticate(client, userId, tenantId, username: "tenantadmin", role: UserRole.TenantAdmin); + /// + /// Seeds an additional tenant and user for tests that need data beyond the bootstrap tenant. + /// public static async Task<(Tenant Tenant, AppUser User)> SeedTenantUserAsync( IServiceProvider services, string username, @@ -98,25 +102,31 @@ public static void AuthenticateAsTenantAdmin( CancellationToken ct = default ) { - await using var scope = services.CreateAsyncScope(); - var dbContext = scope.ServiceProvider.GetRequiredService(); + await using AsyncServiceScope scope = services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + string tenantCodeValue = $"t{Guid.NewGuid():N}"[..12]; + TenantCode tenantCode = TenantCode.FromPersistence(tenantCodeValue); + var tenantId = Guid.NewGuid(); var tenant = new Tenant { - Id = Guid.NewGuid(), + Id = tenantId, TenantId = Guid.Empty, - Code = $"tenant-{Guid.NewGuid():N}", + Code = tenantCode, Name = $"Tenant {username}", - IsActive = tenantIsActive, }; + if (!tenantIsActive) + tenant.Deactivate(); + + Email emailVo = Email.FromPersistence(email); var user = new AppUser { Id = Guid.NewGuid(), - TenantId = tenant.Id, - KeycloakUserId = $"kc-{Guid.NewGuid():N}", + TenantId = tenantId, Username = username, - Email = email, + Email = emailVo, + KeycloakUserId = $"kc-{Guid.NewGuid():N}", IsActive = userIsActive, Role = UserRole.User, }; From b8f40a6b310731fa4ded4c468fd7d825cbbe917d Mon Sep 17 00:00:00 2001 From: "tadeas.zribko" Date: Sat, 4 Apr 2026 16:05:24 +0200 Subject: [PATCH 7/8] feat: Add JasperFx package and enhance database schema initialization - Added JasperFx package reference to Directory.Packages.props and APITemplate.Tests.csproj for improved command handling. - Refactored DatabaseStartupExtensions to conditionally ensure schema creation for TickerQSchedulerDbContext based on registration status. - Updated AuthBootstrapSeeder to utilize TenantCode for tenant lookups, improving code clarity and correctness. - Enhanced WebhooksControllerTests to ensure proper lazy initialization of the HttpClient, improving test reliability. --- Directory.Packages.props | 1 + .../Startup/DatabaseStartupExtensions.cs | 20 +++++++++++++++- .../Persistence/AuthBootstrapSeeder.cs | 3 ++- .../APITemplate.Tests.csproj | 1 + .../CustomWebApplicationFactory.cs | 4 ++++ .../Features/WebhooksControllerTests.cs | 23 +++++++++++-------- 6 files changed, 41 insertions(+), 11 deletions(-) diff --git a/Directory.Packages.props b/Directory.Packages.props index 08f4473f..f56c65c8 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -21,6 +21,7 @@ + diff --git a/src/APITemplate/Api/Extensions/Startup/DatabaseStartupExtensions.cs b/src/APITemplate/Api/Extensions/Startup/DatabaseStartupExtensions.cs index 2c5ef79b..f0d3af6d 100644 --- a/src/APITemplate/Api/Extensions/Startup/DatabaseStartupExtensions.cs +++ b/src/APITemplate/Api/Extensions/Startup/DatabaseStartupExtensions.cs @@ -23,7 +23,7 @@ public static async Task UseDatabaseAsync(this WebApplication app) await EnsureSchemaAsync(sp); await EnsureSchemaAsync(sp); await EnsureSchemaAsync(sp); - await EnsureSchemaAsync(sp); + await EnsureSchemaIfRegisteredAsync(sp); AuthBootstrapSeeder seeder = sp.GetRequiredService(); await seeder.SeedAsync(); @@ -33,7 +33,25 @@ private static async Task EnsureSchemaAsync(IServiceProvider sp) where TContext : DbContext { TContext context = sp.GetRequiredService(); + await EnsureSchemaCoreAsync(context); + } + + /// + /// TickerQ (and its ) is only registered when + /// BackgroundJobs:TickerQ:Enabled is true and Dragonfly is configured. + /// + private static async Task EnsureSchemaIfRegisteredAsync(IServiceProvider sp) + where TContext : DbContext + { + TContext? context = sp.GetService(); + if (context is null) + return; + await EnsureSchemaCoreAsync(context); + } + + private static async Task EnsureSchemaCoreAsync(DbContext context) + { await context.Database.EnsureCreatedAsync(); IRelationalDatabaseCreator creator = context.GetService(); diff --git a/src/Modules/Identity/Persistence/AuthBootstrapSeeder.cs b/src/Modules/Identity/Persistence/AuthBootstrapSeeder.cs index e299f7c6..0e53332e 100644 --- a/src/Modules/Identity/Persistence/AuthBootstrapSeeder.cs +++ b/src/Modules/Identity/Persistence/AuthBootstrapSeeder.cs @@ -42,12 +42,13 @@ private TenantIdentity GetTenantIdentity() private Task FindTenantAsync(string tenantCode, CancellationToken ct) { + TenantCode code = TenantCode.FromPersistence(tenantCode); return _dbContext .Tenants.IgnoreQueryFilters([ GlobalQueryFilterNames.SoftDelete, GlobalQueryFilterNames.Tenant, ]) - .FirstOrDefaultAsync(t => t.Code.Value == tenantCode, ct); + .FirstOrDefaultAsync(t => t.Code == code, ct); } private bool CreateTenant(TenantIdentity tenantIdentity) diff --git a/tests/APITemplate.Tests/APITemplate.Tests.csproj b/tests/APITemplate.Tests/APITemplate.Tests.csproj index 416839f8..cd9d86ed 100644 --- a/tests/APITemplate.Tests/APITemplate.Tests.csproj +++ b/tests/APITemplate.Tests/APITemplate.Tests.csproj @@ -9,6 +9,7 @@ + diff --git a/tests/APITemplate.Tests/Integration/CustomWebApplicationFactory.cs b/tests/APITemplate.Tests/Integration/CustomWebApplicationFactory.cs index c07f533e..1062f3fb 100644 --- a/tests/APITemplate.Tests/Integration/CustomWebApplicationFactory.cs +++ b/tests/APITemplate.Tests/Integration/CustomWebApplicationFactory.cs @@ -1,4 +1,5 @@ using APITemplate.Tests.Integration.Helpers; +using JasperFx.CommandLine; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Mvc.Testing; using Microsoft.AspNetCore.TestHost; @@ -20,6 +21,9 @@ public sealed class CustomWebApplicationFactory : WebApplicationFactory public async ValueTask InitializeAsync() { + // Required so Program.cs can complete RunJasperFxCommands under WebApplicationFactory (see JasperFx docs). + JasperFxEnvironment.AutoStartHost = true; + _postgres = new PostgreSqlBuilder("postgres:16-alpine") .WithUsername("postgres") .WithPassword("postgres") diff --git a/tests/APITemplate.Tests/Integration/Features/WebhooksControllerTests.cs b/tests/APITemplate.Tests/Integration/Features/WebhooksControllerTests.cs index 12a3e641..056a12a5 100644 --- a/tests/APITemplate.Tests/Integration/Features/WebhooksControllerTests.cs +++ b/tests/APITemplate.Tests/Integration/Features/WebhooksControllerTests.cs @@ -2,6 +2,7 @@ using System.Text; using APITemplate.Tests.Integration.Helpers; using APITemplate.Tests.Unit.Helpers; +using Microsoft.AspNetCore.Mvc.Testing; using Shouldly; using Webhooks.Contracts; using Xunit; @@ -11,12 +12,16 @@ namespace APITemplate.Tests.Integration.Features; [Trait("Category", "Integration.Docker")] public sealed class WebhooksControllerTests : IClassFixture { - private readonly HttpClient _client; + private readonly CustomWebApplicationFactory _factory; + private HttpClient? _client; - public WebhooksControllerTests(CustomWebApplicationFactory factory) - { - _client = factory.CreateClient(); - } + /// + /// Lazy client: test class ctor can run before the fixture's , + /// so we must not call in the ctor. + /// + private HttpClient Client => _client ??= _factory.CreateClient(); + + public WebhooksControllerTests(CustomWebApplicationFactory factory) => _factory = factory; [Fact] public async Task Receive_ValidSignature_Returns200() @@ -37,7 +42,7 @@ public async Task Receive_ValidSignature_Returns200() request.Headers.Add(WebhookConstants.SignatureHeader, signature); request.Headers.Add(WebhookConstants.TimestampHeader, timestamp); - var response = await _client.SendAsync(request, ct); + var response = await Client.SendAsync(request, ct); response.StatusCode.ShouldBe(HttpStatusCode.OK); } @@ -55,7 +60,7 @@ public async Task Receive_InvalidSignature_Returns401() request.Headers.Add(WebhookConstants.SignatureHeader, "wrong-signature"); request.Headers.Add(WebhookConstants.TimestampHeader, timestamp); - var response = await _client.SendAsync(request, ct); + var response = await Client.SendAsync(request, ct); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); } @@ -78,7 +83,7 @@ public async Task Receive_ExpiredTimestamp_Returns401() request.Headers.Add(WebhookConstants.SignatureHeader, signature); request.Headers.Add(WebhookConstants.TimestampHeader, expiredTimestamp); - var response = await _client.SendAsync(request, ct); + var response = await Client.SendAsync(request, ct); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); } @@ -94,7 +99,7 @@ public async Task Receive_MissingHeaders_Returns401() }; // No signature or timestamp headers - var response = await _client.SendAsync(request, ct); + var response = await Client.SendAsync(request, ct); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); } } From c7c26155b5ef787da04ffdae9ad8992c2af31c62 Mon Sep 17 00:00:00 2001 From: "tadeas.zribko" Date: Sat, 4 Apr 2026 16:19:04 +0200 Subject: [PATCH 8/8] refactor: Improve test structure and enhance validation logic - Updated Directory.Packages.props to ensure proper package versioning. - Refactored CustomWebApplicationFactory to streamline container initialization for PostgreSQL and MongoDB. - Simplified validation logic in SseStreamRequestValidationTests by utilizing a helper method for property validation. - Removed obsolete methods and improved type usage in various test files, enhancing overall maintainability and clarity. --- Directory.Packages.props | 2 +- .../CustomWebApplicationFactory.cs | 19 ++++++----- .../Features/WebhooksControllerTests.cs | 1 - .../Integration/Helpers/TestServiceHelper.cs | 4 +-- .../SseStreamRequestValidationTests.cs | 19 ++++------- .../Unit/Helpers/DataAnnotationsTestHelper.cs | 24 ++++++++++++++ .../Unit/Helpers/FakeTimeProvider.cs | 19 +++++++++++ .../InMemoryIdempotencyStoreTests.cs | 32 ++++++------------- .../Webhooks/HmacWebhookPayloadSignerTests.cs | 6 +--- .../HmacWebhookPayloadValidatorTests.cs | 5 --- 10 files changed, 70 insertions(+), 61 deletions(-) create mode 100644 tests/APITemplate.Tests/Unit/Helpers/DataAnnotationsTestHelper.cs create mode 100644 tests/APITemplate.Tests/Unit/Helpers/FakeTimeProvider.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index f56c65c8..d61d13b2 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -88,4 +88,4 @@ - \ No newline at end of file + diff --git a/tests/APITemplate.Tests/Integration/CustomWebApplicationFactory.cs b/tests/APITemplate.Tests/Integration/CustomWebApplicationFactory.cs index 1062f3fb..0aa3a698 100644 --- a/tests/APITemplate.Tests/Integration/CustomWebApplicationFactory.cs +++ b/tests/APITemplate.Tests/Integration/CustomWebApplicationFactory.cs @@ -49,16 +49,8 @@ public async ValueTask InitializeAsync() protected override void ConfigureWebHost(IWebHostBuilder builder) { - PostgreSqlContainer postgres = - _postgres - ?? throw new InvalidOperationException( - "PostgreSQL container was not started; IAsyncLifetime.InitializeAsync must run first." - ); - MongoDbContainer mongo = - _mongo - ?? throw new InvalidOperationException( - "MongoDB container was not started; IAsyncLifetime.InitializeAsync must run first." - ); + PostgreSqlContainer postgres = RequireStarted(_postgres, "PostgreSQL"); + MongoDbContainer mongo = RequireStarted(_mongo, "MongoDB"); string pg = postgres.GetConnectionString(); string mongoConnectionString = mongo.GetConnectionString(); @@ -92,4 +84,11 @@ protected override void ConfigureWebHost(IWebHostBuilder builder) builder.UseEnvironment("Development"); } + + private static T RequireStarted(T? container, string label) + where T : class => + container + ?? throw new InvalidOperationException( + $"{label} container was not started; IAsyncLifetime.InitializeAsync must run first." + ); } diff --git a/tests/APITemplate.Tests/Integration/Features/WebhooksControllerTests.cs b/tests/APITemplate.Tests/Integration/Features/WebhooksControllerTests.cs index 056a12a5..898ce385 100644 --- a/tests/APITemplate.Tests/Integration/Features/WebhooksControllerTests.cs +++ b/tests/APITemplate.Tests/Integration/Features/WebhooksControllerTests.cs @@ -97,7 +97,6 @@ public async Task Receive_MissingHeaders_Returns401() { Content = new StringContent(body, Encoding.UTF8, "application/json"), }; - // No signature or timestamp headers var response = await Client.SendAsync(request, ct); response.StatusCode.ShouldBe(HttpStatusCode.Unauthorized); diff --git a/tests/APITemplate.Tests/Integration/Helpers/TestServiceHelper.cs b/tests/APITemplate.Tests/Integration/Helpers/TestServiceHelper.cs index 19bec4b2..a32676c8 100644 --- a/tests/APITemplate.Tests/Integration/Helpers/TestServiceHelper.cs +++ b/tests/APITemplate.Tests/Integration/Helpers/TestServiceHelper.cs @@ -78,9 +78,7 @@ or HealthCheckNames.PostgreSql or HealthCheckNames.Dragonfly ) .ToList(); - foreach ( - Microsoft.Extensions.Diagnostics.HealthChecks.HealthCheckRegistration r in toRemove - ) + foreach (var r in toRemove) options.Registrations.Remove(r); } ); diff --git a/tests/APITemplate.Tests/Unit/Chatting/SseStreamRequestValidationTests.cs b/tests/APITemplate.Tests/Unit/Chatting/SseStreamRequestValidationTests.cs index 66628ea9..dd3d4d38 100644 --- a/tests/APITemplate.Tests/Unit/Chatting/SseStreamRequestValidationTests.cs +++ b/tests/APITemplate.Tests/Unit/Chatting/SseStreamRequestValidationTests.cs @@ -1,4 +1,5 @@ using System.ComponentModel.DataAnnotations; +using APITemplate.Tests.Unit.Helpers; using Chatting.Features.GetNotificationStream; using Shouldly; using Xunit; @@ -14,7 +15,10 @@ public sealed class SseStreamRequestValidationTests public void Validation_WhenCountOutOfRange_Fails(int count) { SseStreamRequest request = new() { Count = count }; - bool valid = TryValidateAll(request, out List results); + bool valid = DataAnnotationsTestHelper.TryValidateAllProperties( + request, + out List results + ); valid.ShouldBeFalse(); results.ShouldNotBeEmpty(); @@ -27,19 +31,8 @@ public void Validation_WhenCountOutOfRange_Fails(int count) public void Validation_WhenCountInRange_Passes(int count) { SseStreamRequest request = new() { Count = count }; - bool valid = TryValidateAll(request, out _); + bool valid = DataAnnotationsTestHelper.TryValidateAllProperties(request, out _); valid.ShouldBeTrue(); } - - private static bool TryValidateAll(object instance, out List results) - { - results = []; - return Validator.TryValidateObject( - instance, - new ValidationContext(instance), - results, - validateAllProperties: true - ); - } } diff --git a/tests/APITemplate.Tests/Unit/Helpers/DataAnnotationsTestHelper.cs b/tests/APITemplate.Tests/Unit/Helpers/DataAnnotationsTestHelper.cs new file mode 100644 index 00000000..a98d4fbb --- /dev/null +++ b/tests/APITemplate.Tests/Unit/Helpers/DataAnnotationsTestHelper.cs @@ -0,0 +1,24 @@ +using System.ComponentModel.DataAnnotations; + +namespace APITemplate.Tests.Unit.Helpers; + +/// +/// Shared DataAnnotations validation for tests (property-level Validator.TryValidateObject, aligned with +/// DataAnnotationsValidator<T> in SharedKernel). +/// +internal static class DataAnnotationsTestHelper +{ + internal static bool TryValidateAllProperties( + object instance, + out List results + ) + { + results = []; + return Validator.TryValidateObject( + instance, + new ValidationContext(instance), + results, + validateAllProperties: true + ); + } +} diff --git a/tests/APITemplate.Tests/Unit/Helpers/FakeTimeProvider.cs b/tests/APITemplate.Tests/Unit/Helpers/FakeTimeProvider.cs new file mode 100644 index 00000000..400e2541 --- /dev/null +++ b/tests/APITemplate.Tests/Unit/Helpers/FakeTimeProvider.cs @@ -0,0 +1,19 @@ +namespace APITemplate.Tests.Unit.Helpers; + +/// Fixed UTC clock for tests that only need a stable . +internal sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider +{ + public override DateTimeOffset GetUtcNow() => utcNow; +} + +/// UTC clock that tests can advance (e.g. TTL / expiry scenarios). +internal sealed class MutableFakeTimeProvider : TimeProvider +{ + private DateTimeOffset _utcNow; + + public MutableFakeTimeProvider(DateTimeOffset utcNow) => _utcNow = utcNow; + + public override DateTimeOffset GetUtcNow() => _utcNow; + + public void Advance(TimeSpan duration) => _utcNow = _utcNow.Add(duration); +} diff --git a/tests/APITemplate.Tests/Unit/Idempotency/InMemoryIdempotencyStoreTests.cs b/tests/APITemplate.Tests/Unit/Idempotency/InMemoryIdempotencyStoreTests.cs index 74575363..7f65cd37 100644 --- a/tests/APITemplate.Tests/Unit/Idempotency/InMemoryIdempotencyStoreTests.cs +++ b/tests/APITemplate.Tests/Unit/Idempotency/InMemoryIdempotencyStoreTests.cs @@ -1,3 +1,4 @@ +using APITemplate.Tests.Unit.Helpers; using SharedKernel.Application.Contracts; using SharedKernel.Infrastructure.Idempotency; using Shouldly; @@ -44,7 +45,7 @@ public async Task TryAcquireAsync_VariousTtlsStillAcquires(int ttlMs) public async Task TryAcquireAsync_WhenLockAlreadyHeld_ReturnsNull() { CancellationToken ct = TestContext.Current.CancellationToken; - FakeTimeProvider time = new(DateTimeOffset.UtcNow); + MutableFakeTimeProvider time = new(DateTimeOffset.UtcNow); InMemoryIdempotencyStore store = new(time); string? first = await store.TryAcquireAsync("key-1", DefaultTtl, ct); @@ -58,7 +59,7 @@ public async Task TryAcquireAsync_WhenLockAlreadyHeld_ReturnsNull() public async Task TryAcquireAsync_WhenCachedResultExists_ReturnsNull() { CancellationToken ct = TestContext.Current.CancellationToken; - FakeTimeProvider time = new(DateTimeOffset.UtcNow); + MutableFakeTimeProvider time = new(DateTimeOffset.UtcNow); InMemoryIdempotencyStore store = new(time); string? token = await store.TryAcquireAsync("key-1", DefaultTtl, ct); @@ -77,7 +78,7 @@ public async Task TryAcquireAsync_WhenCachedResultExists_ReturnsNull() public async Task TryAcquireAsync_WhenCachedResultExpired_ReturnsNewToken() { CancellationToken ct = TestContext.Current.CancellationToken; - FakeTimeProvider time = new(DateTimeOffset.UtcNow); + MutableFakeTimeProvider time = new(DateTimeOffset.UtcNow); InMemoryIdempotencyStore store = new(time); string? token = await store.TryAcquireAsync("key-1", DefaultTtl, ct); @@ -98,7 +99,7 @@ public async Task TryAcquireAsync_WhenCachedResultExpired_ReturnsNewToken() public async Task ReleaseAsync_WithCorrectToken_ReleasesLock() { CancellationToken ct = TestContext.Current.CancellationToken; - FakeTimeProvider time = new(DateTimeOffset.UtcNow); + MutableFakeTimeProvider time = new(DateTimeOffset.UtcNow); InMemoryIdempotencyStore store = new(time); string? token = await store.TryAcquireAsync("key-1", DefaultTtl, ct); @@ -114,7 +115,7 @@ public async Task ReleaseAsync_WithCorrectToken_ReleasesLock() public async Task ReleaseAsync_WithWrongToken_DoesNotReleaseLock() { CancellationToken ct = TestContext.Current.CancellationToken; - FakeTimeProvider time = new(DateTimeOffset.UtcNow); + MutableFakeTimeProvider time = new(DateTimeOffset.UtcNow); InMemoryIdempotencyStore store = new(time); string? token = await store.TryAcquireAsync("key-1", DefaultTtl, ct); @@ -130,7 +131,7 @@ public async Task ReleaseAsync_WithWrongToken_DoesNotReleaseLock() public async Task SetAsync_ThenTryGetAsync_ReturnsCachedEntry() { CancellationToken ct = TestContext.Current.CancellationToken; - FakeTimeProvider time = new(DateTimeOffset.UtcNow); + MutableFakeTimeProvider time = new(DateTimeOffset.UtcNow); InMemoryIdempotencyStore store = new(time); IdempotencyCacheEntry entry = new( @@ -154,7 +155,7 @@ public async Task SetAsync_ThenTryGetAsync_ReturnsCachedEntry() public async Task TryGetAsync_WhenExpired_ReturnsNull() { CancellationToken ct = TestContext.Current.CancellationToken; - FakeTimeProvider time = new(DateTimeOffset.UtcNow); + MutableFakeTimeProvider time = new(DateTimeOffset.UtcNow); InMemoryIdempotencyStore store = new(time); IdempotencyCacheEntry entry = new(200, "{}", "application/json"); @@ -169,22 +170,7 @@ public async Task TryGetAsync_WhenExpired_ReturnsNull() private static (InMemoryIdempotencyStore Store, CancellationToken Ct) CreateStore() { CancellationToken ct = TestContext.Current.CancellationToken; - FakeTimeProvider time = new(DateTimeOffset.UtcNow); + MutableFakeTimeProvider time = new(DateTimeOffset.UtcNow); return (new InMemoryIdempotencyStore(time), ct); } - - private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider - { - private DateTimeOffset _utcNow = utcNow; - - public override DateTimeOffset GetUtcNow() - { - return _utcNow; - } - - public void Advance(TimeSpan duration) - { - _utcNow = _utcNow.Add(duration); - } - } } diff --git a/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadSignerTests.cs b/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadSignerTests.cs index d857d770..06ae426e 100644 --- a/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadSignerTests.cs +++ b/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadSignerTests.cs @@ -1,3 +1,4 @@ +using APITemplate.Tests.Unit.Helpers; using Microsoft.Extensions.Options; using Shouldly; using Webhooks.Contracts; @@ -53,9 +54,4 @@ public void Sign_TimestampReflectsTimeProvider() result.Timestamp.ShouldBe(fixedTime.ToUnixTimeSeconds().ToString()); } - - private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider - { - public override DateTimeOffset GetUtcNow() => utcNow; - } } diff --git a/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadValidatorTests.cs b/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadValidatorTests.cs index 7dadfadc..bc290fe5 100644 --- a/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadValidatorTests.cs +++ b/tests/APITemplate.Tests/Unit/Webhooks/HmacWebhookPayloadValidatorTests.cs @@ -100,9 +100,4 @@ public void IsValid_ExactBoundary_ReturnsTrue() validator.IsValid(payload, signature, boundaryTimestamp).ShouldBeTrue(); } - - private sealed class FakeTimeProvider(DateTimeOffset utcNow) : TimeProvider - { - public override DateTimeOffset GetUtcNow() => utcNow; - } }