diff --git a/EnvelopeGenerator.Application/Envelopes/Commands/CreateEnvelopeCommand.cs b/EnvelopeGenerator.Application/Envelopes/Commands/CreateEnvelopeCommand.cs index 28f4ea86..a0b4afab 100644 --- a/EnvelopeGenerator.Application/Envelopes/Commands/CreateEnvelopeCommand.cs +++ b/EnvelopeGenerator.Application/Envelopes/Commands/CreateEnvelopeCommand.cs @@ -130,10 +130,27 @@ public record CreateEnvelopeCommand : IRequest public bool UseSQLExecutor { get; set; } = true; } +/// +/// +/// +/// public record CreateEnvelopeDocumentDto(string DataAsBase64); +/// +/// +/// +/// +/// +/// public record CreateEnvelopeFieldDto(double X, double Y, int Page); +/// +/// +/// +/// +/// +/// +/// public record CreateEnvelopeReceiverDto( string EmailAddress, string? Name, diff --git a/EnvelopeGenerator.Application/Envelopes/Commands/CreateEnvelopeCommandHandler.cs b/EnvelopeGenerator.Application/Envelopes/Commands/CreateEnvelopeCommandHandler.cs index 888d5b20..5097fda5 100644 --- a/EnvelopeGenerator.Application/Envelopes/Commands/CreateEnvelopeCommandHandler.cs +++ b/EnvelopeGenerator.Application/Envelopes/Commands/CreateEnvelopeCommandHandler.cs @@ -49,6 +49,7 @@ public class CreateEnvelopeCommandHandler : IRequestHandler _provider.GetRequiredService>().CurrentValue; + [Obsolete("Use IConfigService instead.")] private IConfigService ConfigService => _provider.GetRequiredService(); private IMediator Mediator => _provider.GetRequiredService(); diff --git a/EnvelopeGenerator.Tests/Application/Fake.cs b/EnvelopeGenerator.Tests/Application/Fake.cs index 3d3265ea..8c0d8f04 100644 --- a/EnvelopeGenerator.Tests/Application/Fake.cs +++ b/EnvelopeGenerator.Tests/Application/Fake.cs @@ -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", diff --git a/EnvelopeGenerator.Tests/EnvelopeGenerator.Tests.csproj b/EnvelopeGenerator.Tests/EnvelopeGenerator.Tests.csproj index c0c01185..0c155811 100644 --- a/EnvelopeGenerator.Tests/EnvelopeGenerator.Tests.csproj +++ b/EnvelopeGenerator.Tests/EnvelopeGenerator.Tests.csproj @@ -71,10 +71,12 @@ + + diff --git a/EnvelopeGenerator.Tests/Infrastructure/MailTemplatePlaceholderDbTests.cs b/EnvelopeGenerator.Tests/Infrastructure/MailTemplatePlaceholderDbTests.cs new file mode 100644 index 00000000..4dfd95c3 --- /dev/null +++ b/EnvelopeGenerator.Tests/Infrastructure/MailTemplatePlaceholderDbTests.cs @@ -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 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 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(); + 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); + + /// + /// 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. + /// + 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__"; + } +} diff --git a/EnvelopeGenerator.Tests/Infrastructure/ReplacePlaceholderExtensionsTests.cs b/EnvelopeGenerator.Tests/Infrastructure/ReplacePlaceholderExtensionsTests.cs new file mode 100644 index 00000000..283a6d9b --- /dev/null +++ b/EnvelopeGenerator.Tests/Infrastructure/ReplacePlaceholderExtensionsTests.cs @@ -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 NamingCases() + { + yield return Naming("[FIRST_NAME]"); + yield return Naming("[LAST_NAME]"); + yield return Naming("[ID]"); + yield return Naming("[ID]"); + yield return Naming("[URL_VALUE]"); + yield return Naming("[HTTP_STATUS_CODE]"); + yield return Naming("[XML_HTTP_REQUEST]"); + yield return Naming("[OTP]"); + yield return Naming("[FIELD1]"); + yield return Naming("[VALUE2_TEXT]"); + yield return Naming("[A1_B2]"); + yield return Naming("[NAME2]"); + yield return Naming("[MY_URL2_VALUE]"); + yield return Naming("[X]"); + yield return Naming("[FIRST_NAME]"); + yield return Naming("[FIRST_NAME]"); + yield return Naming("[TFA_EXPIRATION]"); + } + + private static TestCaseData Naming(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 { Value = "boxed" }); + + Assert.That(result, Is.EqualTo("boxed")); + } + + [Test] + public void Generic_type_is_supported_for_value_type() + { + var result = "[VALUE]".ReplacePlaceholders(new Box { 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 + { + 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 +}