Add DTOs and tests for placeholder replacement

Added new record types `CreateEnvelopeDocumentDto`, `CreateEnvelopeFieldDto`, and `CreateEnvelopeReceiverDto` with XML documentation in `CreateEnvelopeCommand.cs`. Marked `ConfigService` as obsolete in `CreateEnvelopeCommandHandler.cs`. Modified `CertificationType` usage in `Fake.cs` to use the enum directly. Updated `EnvelopeGenerator.Tests.csproj` with a new project reference and compile directives. Introduced `MailTemplatePlaceholderDbTests` and `ReplacePlaceholderExtensionsTests` to verify placeholder replacement functionality. Added supporting model classes for testing. These changes improve code functionality and test coverage.
This commit is contained in:
2026-10-29 09:53:52 +01:00
parent a35e05d016
commit d913b949e0
6 changed files with 644 additions and 1 deletions

View File

@@ -130,10 +130,27 @@ public record CreateEnvelopeCommand : IRequest<EnvelopeDto?>
public bool UseSQLExecutor { get; set; } = true;
}
/// <summary>
///
/// </summary>
/// <param name="DataAsBase64"></param>
public record CreateEnvelopeDocumentDto(string DataAsBase64);
/// <summary>
///
/// </summary>
/// <param name="X"></param>
/// <param name="Y"></param>
/// <param name="Page"></param>
public record CreateEnvelopeFieldDto(double X, double Y, int Page);
/// <summary>
///
/// </summary>
/// <param name="EmailAddress"></param>
/// <param name="Name"></param>
/// <param name="PhoneNumber"></param>
/// <param name="Fields"></param>
public record CreateEnvelopeReceiverDto(
string EmailAddress,
string? Name,

View File

@@ -49,6 +49,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler<CreateEnvelopeComman
private SignatureFieldParams SignatureFieldParams => _provider.GetRequiredService<IOptionsMonitor<SignatureFieldParams>>().CurrentValue;
[Obsolete("Use IConfigService instead.")]
private IConfigService ConfigService => _provider.GetRequiredService<IConfigService>();
private IMediator Mediator => _provider.GetRequiredService<IMediator>();

View File

@@ -215,7 +215,7 @@ public static class Extensions
Message = faker.Lorem.Paragraph(faker.Random.Number(2, 5)),
TfaEnabled = tfaEnabled,
AddedWhen = DateTime.Now,
CertificationType = (int)CertificationType.AdvancedElectronicSignature,
CertificationType = CertificationType.AdvancedElectronicSignature,
UseAccessCode = false,
ContractType = (int)ContractType.Contract,
Language = "de",

View File

@@ -71,10 +71,12 @@
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">
<ProjectReference Include="..\EnvelopeGenerator.Infrastructure.Crypt\EnvelopeGenerator.Infrastructure.Crypt.csproj" />
<ProjectReference Include="..\EnvelopeGenerator.Infrastructure.Notification\EnvelopeGenerator.Infrastructure.Notification.csproj" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net7.0'">
<Compile Remove="Application\Crypt\**\*.cs" />
<Compile Remove="Infrastructure\**\*.cs" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net8.0'">

View File

@@ -0,0 +1,145 @@
using System.Text.Json;
using System.Text.RegularExpressions;
using EnvelopeGenerator.Infrastructure.Notification;
using Microsoft.Data.SqlClient;
namespace EnvelopeGenerator.Tests.Infrastructure;
[TestFixture]
public class MailTemplatePlaceholderDbTests
{
private static readonly Regex PlaceholderPattern = new(@"\[[A-Z0-9_]+\]", RegexOptions.Compiled);
private static IEnumerable<TestCaseData> DbTemplateCases()
{
foreach (var template in ReadTemplates())
{
yield return new TestCaseData(template)
{
TestName = $"Template_{template.Name}_{template.LangCode}_{template.Guid}"
};
}
}
[Test]
public void Database_contains_templates()
{
Assert.That(ReadTemplates(), Is.Not.Empty);
}
[TestCaseSource(nameof(DbTemplateCases))]
public void Every_placeholder_in_every_db_template_is_replaced(TemplateRow template)
{
var data = new TemplatePlaceholders();
var subject = template.Subject.ReplacePlaceholders(data);
var body = template.Body.ReplacePlaceholders(data);
var originalTokens = PlaceholderPattern
.Matches(template.Subject + template.Body)
.Select(m => m.Value)
.Distinct()
.ToArray();
Assert.Multiple(() =>
{
Assert.That(Unresolved(subject), Is.Empty,
$"'{template.Name}' subject has unresolved placeholders.");
Assert.That(Unresolved(body), Is.Empty,
$"'{template.Name}' body has unresolved placeholders.");
foreach (var token in originalTokens)
{
Assert.That(subject + body, Does.Contain(Expected(token)),
$"'{template.Name}' placeholder {token} was not replaced with its value.");
}
});
}
private static string[] Unresolved(string text)
=> PlaceholderPattern.Matches(text)
.Select(m => m.Value)
.Distinct()
.ToArray();
private static string Expected(string token) => $"__{token.Trim('[', ']')}__";
private static List<TemplateRow> ReadTemplates()
{
const string sql = "SELECT GUID, NAME, SUBJECT, BODY, LANG_CODE FROM TBSIG_EMAIL_TEMPLATE";
using var connection = new SqlConnection(ReadConnectionString());
connection.Open();
using var command = new SqlCommand(sql, connection);
using var reader = command.ExecuteReader();
var templates = new List<TemplateRow>();
while (reader.Read())
{
templates.Add(new TemplateRow(
reader.GetInt32(0),
reader.GetString(1),
reader.IsDBNull(2) ? string.Empty : reader.GetString(2),
reader.IsDBNull(3) ? string.Empty : reader.GetString(3),
reader.IsDBNull(4) ? string.Empty : reader.GetString(4)));
}
return templates;
}
private static string ReadConnectionString()
{
var directory = new DirectoryInfo(AppContext.BaseDirectory);
while (directory is not null && !File.Exists(Path.Combine(directory.FullName, "EnvelopeGenerator.sln")))
directory = directory.Parent;
if (directory is null)
throw new InvalidOperationException("Solution root (EnvelopeGenerator.sln) was not found.");
var settingsPath = Path.Combine(
directory.FullName,
"EnvelopeGenerator.Server",
"EnvelopeGenerator.Server",
"appsettings.json");
using var stream = File.OpenRead(settingsPath);
using var document = JsonDocument.Parse(stream, new JsonDocumentOptions
{
CommentHandling = JsonCommentHandling.Skip,
AllowTrailingCommas = true
});
return document.RootElement
.GetProperty("ConnectionStrings")
.GetProperty("Default")
.GetString()
?? throw new InvalidOperationException($"ConnectionStrings:Default is missing in '{settingsPath}'.");
}
public sealed record TemplateRow(int Guid, string Name, string Subject, string Body, string LangCode);
/// <summary>
/// One property per placeholder used by any row in TBSIG_EMAIL_TEMPLATE.
/// The sentinel value equals the placeholder name, so a wrong mapping is detected.
/// </summary>
private sealed class TemplatePlaceholders
{
public string DocumentProcess { get; set; } = "__DOCUMENT_PROCESS__";
public string DocumentTitle { get; set; } = "__DOCUMENT_TITLE__";
public string LinkToDocumentText { get; set; } = "__LINK_TO_DOCUMENT_TEXT__";
public string LinkToDocument { get; set; } = "__LINK_TO_DOCUMENT__";
public string Message { get; set; } = "__MESSAGE__";
public string NamePortal { get; set; } = "__NAME_PORTAL__";
public string NameReceiver { get; set; } = "__NAME_RECEIVER__";
public string NameSender { get; set; } = "__NAME_SENDER__";
public string SignatureType { get; set; } = "__SIGNATURE_TYPE__";
public string Reason { get; set; } = "__REASON__";
public string ReceiverAction { get; set; } = "__RECEIVER_ACTION__";
public string FinalAction { get; set; } = "__FINAL_ACTION__";
public string FinalStatus { get; set; } = "__FINAL_STATUS__";
public string DocumentAccessCode { get; set; } = "__DOCUMENT_ACCESS_CODE__";
public string TfaExpiration { get; set; } = "__TFA_EXPIRATION__";
public string TfaQrCode { get; set; } = "__TFA_QR_CODE__";
}
}

View File

@@ -0,0 +1,478 @@
using EnvelopeGenerator.Application.Common.Interfaces.Services.Notification;
using EnvelopeGenerator.Infrastructure.Notification;
namespace EnvelopeGenerator.Tests.Infrastructure;
[TestFixture]
public class ReplacePlaceholderExtensionsTests
{
#region Naming
private static IEnumerable<TestCaseData> NamingCases()
{
yield return Naming<PersonModel>("[FIRST_NAME]");
yield return Naming<PersonModel>("[LAST_NAME]");
yield return Naming<IdModel>("[ID]");
yield return Naming<UpperIdModel>("[ID]");
yield return Naming<AcronymModel>("[URL_VALUE]");
yield return Naming<AcronymModel>("[HTTP_STATUS_CODE]");
yield return Naming<AcronymModel>("[XML_HTTP_REQUEST]");
yield return Naming<AcronymModel>("[OTP]");
yield return Naming<DigitModel>("[FIELD1]");
yield return Naming<DigitModel>("[VALUE2_TEXT]");
yield return Naming<DigitModel>("[A1_B2]");
yield return Naming<DigitModel>("[NAME2]");
yield return Naming<MyUrl2ValueModel>("[MY_URL2_VALUE]");
yield return Naming<SingleLetterModel>("[X]");
yield return Naming<LowerCaseModel>("[FIRST_NAME]");
yield return Naming<SnakeCaseModel>("[FIRST_NAME]");
yield return Naming<TfaModel>("[TFA_EXPIRATION]");
}
private static TestCaseData Naming<T>(string placeholder) where T : new()
=> new(new T(), placeholder)
{
TestName = $"Placeholder_{typeof(T).Name}_{placeholder.Trim('[', ']')}"
};
[TestCaseSource(nameof(NamingCases))]
public void Property_names_are_converted_to_upper_snake_case(object data, string placeholder)
{
var result = placeholder.ReplacePlaceholders(data);
Assert.That(result, Is.EqualTo("v"));
}
[Test]
[SetCulture("tr-TR")]
public void Placeholder_names_are_culture_invariant()
{
var result = "[INFO]".ReplacePlaceholders(new LowerCaseInfoModel());
Assert.That(result, Is.EqualTo("v"), "Turkish casing would produce [\u0130NFO]");
}
#endregion
#region Basic replacement
[Test]
public void Replaces_single_placeholder()
{
var result = "[FIRST_NAME]".ReplacePlaceholders(new PersonModel { FirstName = "Ada" });
Assert.That(result, Is.EqualTo("Ada"));
}
[Test]
public void Replaces_multiple_placeholders()
{
var data = new PersonModel { FirstName = "Ada", LastName = "Lovelace" };
var result = "[FIRST_NAME] [LAST_NAME]".ReplacePlaceholders(data);
Assert.That(result, Is.EqualTo("Ada Lovelace"));
}
[Test]
public void Replaces_all_repeated_occurrences()
{
var data = new PersonModel { FirstName = "Ada" };
var result = "[FIRST_NAME]-[FIRST_NAME]-[FIRST_NAME]".ReplacePlaceholders(data);
Assert.That(result, Is.EqualTo("Ada-Ada-Ada"));
}
[Test]
public void Leaves_unknown_placeholder_untouched()
{
var result = "[UNKNOWN_TOKEN]".ReplacePlaceholders(new PersonModel());
Assert.That(result, Is.EqualTo("[UNKNOWN_TOKEN]"));
}
[Test]
public void Returns_template_unchanged_when_it_has_no_placeholders()
{
var result = "Hello world".ReplacePlaceholders(new PersonModel());
Assert.That(result, Is.EqualTo("Hello world"));
}
[Test]
public void Returns_empty_string_for_empty_template()
{
var result = string.Empty.ReplacePlaceholders(new PersonModel());
Assert.That(result, Is.Empty);
}
[Test]
public void Returns_template_unchanged_when_data_has_no_readable_properties()
{
var result = "[ANYTHING]".ReplacePlaceholders(new object());
Assert.That(result, Is.EqualTo("[ANYTHING]"));
}
[Test]
public void Matching_is_case_sensitive()
{
var result = "[name]|[Name]".ReplacePlaceholders(new PersonNameModel { Name = "v" });
Assert.That(result, Is.EqualTo("[name]|[Name]"));
}
[Test]
public void Placeholder_without_brackets_is_untouched()
{
var result = "NAME Receiver".ReplacePlaceholders(new PersonNameModel { Name = "v" });
Assert.That(result, Is.EqualTo("NAME Receiver"));
}
[Test]
public void Double_brackets_leave_the_outer_brackets()
{
var result = "[[NAME]]".ReplacePlaceholders(new PersonNameModel { Name = "v" });
Assert.That(result, Is.EqualTo("[v]"));
}
#endregion
#region Values
[Test]
public void Null_value_is_replaced_with_empty_string()
{
var result = "[NAME]".ReplacePlaceholders(new { Name = (string?)null });
Assert.That(result, Is.Empty);
}
[Test]
public void Custom_ToString_is_used()
{
var result = "[VALUE]".ReplacePlaceholders(new { Value = new CustomValue() });
Assert.That(result, Is.EqualTo("custom"));
}
[Test]
public void Primitive_values_use_ToString()
{
var result = "[COUNT]|[ENABLED]".ReplacePlaceholders(new { Count = 3, Enabled = true });
Assert.That(result, Is.EqualTo("3|True"));
}
[Test]
public void Regex_like_characters_are_inserted_literally()
{
var result = "[PATTERN]".ReplacePlaceholders(new { Pattern = @"$1 {0} \ $&" });
Assert.That(result, Is.EqualTo(@"$1 {0} \ $&"));
}
[Test]
public void Value_containing_its_own_placeholder_is_not_expanded_recursively()
{
var result = "[NAME]".ReplacePlaceholders(new EchoNameModel());
Assert.That(result, Is.EqualTo("[NAME]"));
}
[Test]
public void Unicode_value_is_preserved()
{
var result = "[NAME]".ReplacePlaceholders(new PersonNameModel { Name = "Z\u00fcrich \ud83d\ude00" });
Assert.That(result, Is.EqualTo("Z\u00fcrich \ud83d\ude00"));
}
#endregion
#region Property discovery
[Test]
public void Public_readable_property_is_replaced()
{
var result = "[SHOWN]".ReplacePlaceholders(new DiscoveryModel());
Assert.That(result, Is.EqualTo("shown"));
}
[Test]
public void Get_only_property_is_replaced()
{
var result = "[READ_ONLY]".ReplacePlaceholders(new DiscoveryModel());
Assert.That(result, Is.EqualTo("computed"));
}
[Test]
public void Static_private_write_only_and_indexer_properties_are_ignored()
{
const string template = "[HIDDEN]|[STATIC]|[WRITE_ONLY]|[ITEM]";
var result = template.ReplacePlaceholders(new DiscoveryModel());
Assert.That(result, Is.EqualTo(template));
}
[Test]
public void Inherited_public_property_is_replaced()
{
var result = "[ID]|[NAME]".ReplacePlaceholders(new DerivedModel());
Assert.That(result, Is.EqualTo("base|derived"));
}
[Test]
public void Record_properties_are_replaced()
{
var result = "[FIRST_NAME]/[AGE]".ReplacePlaceholders(new PersonRecord("Ada", 36));
Assert.That(result, Is.EqualTo("Ada/36"));
}
[Test]
public void Struct_data_is_supported()
{
var result = "[NAME]".ReplacePlaceholders(new StructModel { Name = "struct" });
Assert.That(result, Is.EqualTo("struct"));
}
[Test]
public void Anonymous_type_is_supported()
{
var data = new { Name = "anon", Age = 42 };
var result = "[NAME]|[AGE]".ReplacePlaceholders(data);
Assert.That(result, Is.EqualTo("anon|42"));
}
[Test]
public void Generic_type_is_supported_for_reference_type()
{
var result = "[VALUE]".ReplacePlaceholders(new Box<string> { Value = "boxed" });
Assert.That(result, Is.EqualTo("boxed"));
}
[Test]
public void Generic_type_is_supported_for_value_type()
{
var result = "[VALUE]".ReplacePlaceholders(new Box<int> { Value = 5 });
Assert.That(result, Is.EqualTo("5"));
}
#endregion
#region Cache and thread safety
[Test]
public void Cached_metadata_uses_values_of_current_instance()
{
var first = "[FIRST_NAME]".ReplacePlaceholders(new PersonModel { FirstName = "first" });
var second = "[FIRST_NAME]".ReplacePlaceholders(new PersonModel { FirstName = "second" });
Assert.That(new[] { first, second }, Is.EqualTo(new[] { "first", "second" }));
}
[Test]
public void Two_types_with_same_property_name_are_resolved_independently()
{
var a = "[NAME]".ReplacePlaceholders(new TypeA());
var b = "[NAME]".ReplacePlaceholders(new TypeB());
Assert.That(new[] { a, b }, Is.EqualTo(new[] { "a", "b" }));
}
[Test]
public void Concurrent_first_calls_are_thread_safe()
{
var failures = 0;
Parallel.For(0, 500, i =>
{
var expected = $"n{i}";
var actual = "[NAME]".ReplacePlaceholders(new ConcurrentModel { Name = expected });
if (actual != expected)
Interlocked.Increment(ref failures);
});
Assert.That(failures, Is.Zero);
}
#endregion
#region Real DTO
[Test]
public void Document_received_notification_is_fully_replaced()
{
var data = new DocumentReceivedNotification
{
DocumentTitle = "Vertrag",
NameReceiver = "Max",
NameSender = "Erika",
IsReadAndConfirm = true,
AdvancedSignature = false,
LinkToDocument = "https://x.io/a",
Message = "Bitte",
NamePortal = "Portal"
};
const string template =
"[NAME_RECEIVER]|[NAME_SENDER]|[SIGNATURE_TYPE]|[DOCUMENT_TITLE]|[MESSAGE]|[NAME_PORTAL]|[LINK_TO_DOCUMENT]|[LINK_TO_DOCUMENT_TEXT]";
var result = template.ReplacePlaceholders(data);
Assert.That(result, Is.EqualTo("Max|Erika|Signieren|Vertrag|Bitte|Portal|https://x.io/a|https://x.io/a"));
}
#endregion
#region Models
private sealed class PersonModel
{
public string FirstName { get; set; } = "v";
public string LastName { get; set; } = "v";
}
private sealed class PersonNameModel
{
public string Name { get; set; } = "v";
}
private sealed class IdModel
{
public string Id { get; set; } = "v";
}
private sealed class UpperIdModel
{
public string ID { get; set; } = "v";
}
private sealed class AcronymModel
{
public string URLValue { get; set; } = "v";
public string HTTPStatusCode { get; set; } = "v";
public string XMLHttpRequest { get; set; } = "v";
public string OTP { get; set; } = "v";
}
private sealed class DigitModel
{
public string Field1 { get; set; } = "v";
public string Value2Text { get; set; } = "v";
public string A1B2 { get; set; } = "v";
public string Name2 { get; set; } = "v";
}
private sealed class MyUrl2ValueModel
{
public string MyURL2Value { get; set; } = "v";
}
private sealed class SingleLetterModel
{
public string X { get; set; } = "v";
}
private sealed class LowerCaseModel
{
public string firstName { get; set; } = "v";
}
private sealed class LowerCaseInfoModel
{
public string info { get; set; } = "v";
}
private sealed class SnakeCaseModel
{
public string First_Name { get; set; } = "v";
}
private sealed class TfaModel
{
public string TFAExpiration { get; set; } = "v";
}
private sealed class CustomValue
{
public override string ToString() => "custom";
}
private sealed class EchoNameModel
{
public string Name { get; set; } = "[NAME]";
}
private sealed class DiscoveryModel
{
public static string Static { get; set; } = "static";
public string ReadOnly => "computed";
public string WriteOnly
{
set { }
}
public string this[int index] => "indexer";
public string Shown { get; set; } = "shown";
private string Hidden { get; set; } = "hidden";
}
private class BaseModel
{
public string Id { get; set; } = "base";
}
private sealed class DerivedModel : BaseModel
{
public string Name { get; set; } = "derived";
}
private sealed record PersonRecord(string FirstName, int Age);
private struct StructModel
{
public string Name { get; set; }
}
private sealed class Box<T>
{
public T Value { get; set; } = default!;
}
private sealed class TypeA
{
public string Name { get; set; } = "a";
}
private sealed class TypeB
{
public string Name { get; set; } = "b";
}
private sealed class ConcurrentModel
{
public string Name { get; set; } = string.Empty;
}
#endregion
}