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 }