diff --git a/.gitignore b/.gitignore index fd3586545..d6dbfd9b4 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,5 @@ node_modules bower_components npm-debug.log +/.vs +/jobs/Backend/Task/.vs diff --git a/jobs/Backend/Task/.editorconfig b/jobs/Backend/Task/.editorconfig new file mode 100644 index 000000000..4a9b6ca55 --- /dev/null +++ b/jobs/Backend/Task/.editorconfig @@ -0,0 +1,262 @@ +# Remove the line below if you want to inherit .editorconfig settings from higher directories +root = true + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 4 +indent_style = space +tab_width = 4 + +# New line preferences +end_of_line = crlf +insert_final_newline = false + +#### .NET Coding Conventions #### + +# Organize usings +dotnet_separate_import_directive_groups = false +dotnet_sort_system_directives_first = false +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = false +dotnet_style_qualification_for_field = false +dotnet_style_qualification_for_method = false +dotnet_style_qualification_for_property = false + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true +dotnet_style_predefined_type_for_member_access = true + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity +dotnet_style_parentheses_in_other_operators = never_if_unnecessary +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members + +# Expression-level preferences +dotnet_style_coalesce_expression = true +dotnet_style_collection_initializer = true +dotnet_style_explicit_tuple_names = true +dotnet_style_namespace_match_folder = true +dotnet_style_null_propagation = true +dotnet_style_object_initializer = true +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true +dotnet_style_prefer_collection_expression = when_types_loosely_match +dotnet_style_prefer_compound_assignment = true +dotnet_style_prefer_conditional_expression_over_assignment = true +dotnet_style_prefer_conditional_expression_over_return = true +dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed +dotnet_style_prefer_inferred_anonymous_type_member_names = true +dotnet_style_prefer_inferred_tuple_names = true +dotnet_style_prefer_is_null_check_over_reference_equality_method = true +dotnet_style_prefer_simplified_boolean_expressions = true +dotnet_style_prefer_simplified_interpolation = true + +# Field preferences +dotnet_style_readonly_field = true + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:silent + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +# New line preferences +dotnet_style_allow_multiple_blank_lines_experimental = true +dotnet_style_allow_statement_immediately_after_block_experimental = true + +#### C# Coding Conventions #### + +# var preferences +csharp_style_var_elsewhere = false +csharp_style_var_for_built_in_types = false +csharp_style_var_when_type_is_apparent = false + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_lambdas = true:silent +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true +csharp_style_pattern_matching_over_is_with_cast_check = true +csharp_style_prefer_extended_property_pattern = true +csharp_style_prefer_not_pattern = true +csharp_style_prefer_pattern_matching = true +csharp_style_prefer_switch_expression = true + +# Null-checking preferences +csharp_style_conditional_delegate_call = true + +# Modifier preferences +csharp_prefer_static_local_function = true +csharp_preferred_modifier_order = public,private,protected,internal,file,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async +csharp_style_prefer_readonly_struct = true +csharp_style_prefer_readonly_struct_member = true + +# Code-block preferences +csharp_prefer_braces = true:silent +csharp_prefer_simple_using_statement = true:suggestion +csharp_style_namespace_declarations =file_scoped:suggestion +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_top_level_statements = true:silent + +# Expression-level preferences +csharp_prefer_simple_default_expression = true +csharp_style_deconstructed_variable_declaration = true +csharp_style_implicit_object_creation_when_type_is_apparent = true +csharp_style_inlined_variable_declaration = true +csharp_style_prefer_index_operator = true +csharp_style_prefer_local_over_anonymous_function = true +csharp_style_prefer_null_check_over_type_check = true +csharp_style_prefer_range_operator = true +csharp_style_prefer_tuple_swap = true +csharp_style_prefer_utf8_string_literals = true +csharp_style_throw_expression = true +csharp_style_unused_value_assignment_preference = discard_variable +csharp_style_unused_value_expression_statement_preference = discard_variable + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:silent + +# New line preferences +csharp_style_allow_blank_line_after_colon_in_constructor_initializer_experimental = true +csharp_style_allow_blank_line_after_token_in_arrow_expression_clause_experimental = true +csharp_style_allow_blank_line_after_token_in_conditional_expression_experimental = true +csharp_style_allow_blank_lines_between_consecutive_braces_experimental = true +csharp_style_allow_embedded_statements_on_same_line_experimental = true + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Naming styles #### + +# Naming rules + +dotnet_naming_rule.interface_should_be_begins_with_i.severity = suggestion +dotnet_naming_rule.interface_should_be_begins_with_i.symbols = interface +dotnet_naming_rule.interface_should_be_begins_with_i.style = begins_with_i + +dotnet_naming_rule.types_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.types_should_be_pascal_case.symbols = types +dotnet_naming_rule.types_should_be_pascal_case.style = pascal_case + +dotnet_naming_rule.non_field_members_should_be_pascal_case.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascal_case.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascal_case.style = pascal_case + + # Appy our rule to private fields. +dotnet_naming_rule.private_fields_must_begin_with_underscore_and_be_in_camel_case.symbols = private_fields +dotnet_naming_rule.private_fields_must_begin_with_underscore_and_be_in_camel_case.style = require_underscore_prefix_and_camel_case +dotnet_naming_rule.private_fields_must_begin_with_underscore_and_be_in_camel_case.severity = warning + + + # Define what we will treat as private fields. +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private + +# Symbol specifications + +dotnet_naming_symbols.interface.applicable_kinds = interface +dotnet_naming_symbols.interface.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interface.required_modifiers = + +dotnet_naming_symbols.types.applicable_kinds = class, struct, interface, enum +dotnet_naming_symbols.types.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + # Define rule that something must begin with an underscore and be in camel case. +dotnet_naming_style.require_underscore_prefix_and_camel_case.required_prefix = _ +dotnet_naming_style.require_underscore_prefix_and_camel_case.capitalization = camel_case + +# Naming styles + +dotnet_naming_style.pascal_case.required_prefix = +dotnet_naming_style.pascal_case.required_suffix = +dotnet_naming_style.pascal_case.word_separator = +dotnet_naming_style.pascal_case.capitalization = pascal_case + +dotnet_naming_style.begins_with_i.required_prefix = I +dotnet_naming_style.begins_with_i.required_suffix = +dotnet_naming_style.begins_with_i.word_separator = +dotnet_naming_style.begins_with_i.capitalization = pascal_case +csharp_prefer_system_threading_lock = true:suggestion + +[*.{cs,vb}] +dotnet_diagnostic.IDE0039.severity = none # allow local lambda functions +dotnet_diagnostic.IDE0057.severity = none # Range Operator instead of substring +dotnet_diagnostic.IDE0270.severity = none # prefer is null to null coalescing operator ?? +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_auto_properties = true:silent +dotnet_style_object_initializer = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:silent +dotnet_style_operator_placement_when_wrapping = beginning_of_line +tab_width = 4 +indent_size = 4 +end_of_line = crlf \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs deleted file mode 100644 index 58c5bb10e..000000000 --- a/jobs/Backend/Task/ExchangeRate.cs +++ /dev/null @@ -1,23 +0,0 @@ -namespace ExchangeRateUpdater -{ - public class ExchangeRate - { - public ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value) - { - SourceCurrency = sourceCurrency; - TargetCurrency = targetCurrency; - Value = value; - } - - public Currency SourceCurrency { get; } - - public Currency TargetCurrency { get; } - - public decimal Value { get; } - - public override string ToString() - { - return $"{SourceCurrency}/{TargetCurrency}={Value}"; - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs deleted file mode 100644 index 6f82a97fb..000000000 --- a/jobs/Backend/Task/ExchangeRateProvider.cs +++ /dev/null @@ -1,19 +0,0 @@ -using System.Collections.Generic; -using System.Linq; - -namespace ExchangeRateUpdater -{ - public class ExchangeRateProvider - { - /// - /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined - /// by the source, do not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK", - /// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide - /// some of the currencies, ignore them. - /// - public IEnumerable GetExchangeRates(IEnumerable currencies) - { - return Enumerable.Empty(); - } - } -} diff --git a/jobs/Backend/Task/ExchangeRateUpdater.csproj b/jobs/Backend/Task/ExchangeRateUpdater.csproj deleted file mode 100644 index 2fc654a12..000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.csproj +++ /dev/null @@ -1,8 +0,0 @@ - - - - Exe - net6.0 - - - \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateUpdater.sln b/jobs/Backend/Task/ExchangeRateUpdater.sln index 89be84daf..f7a6a119b 100644 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ b/jobs/Backend/Task/ExchangeRateUpdater.sln @@ -1,9 +1,34 @@  Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25123.0 +# Visual Studio Version 17 +VisualStudioVersion = 17.12.35707.178 MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "01-EntryPoints", "01-EntryPoints", "{28AE7DF2-75FF-4701-8D7E-2DCEBACB4300}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "02-Domain", "02-Domain", "{8A28BE11-C311-4822-8CC9-58103D85B90A}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "03-Infrastructure", "03-Infrastructure", "{B7E7D836-B8B5-4F5B-B699-EB3AC6B0079C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mews.ExchangeRateUpdater.App", "Mews.ExchangeRateUpdater.App\Mews.ExchangeRateUpdater.App.csproj", "{A4EF7F56-F273-45B5-80AD-87D95754F68C}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mews.ExchangeRates.Domain", "Mews.ExchangeRates.Domain\Mews.ExchangeRates.Domain.csproj", "{C929BECF-D483-439C-AEA7-62EA492F090F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mews.CzechNationalBankRateReader", "Mews.CzechNationalBankRateReader\Mews.CzechNationalBankRateReader.csproj", "{80A71042-B220-4733-A5D9-0A98476DB9D2}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "04-Tests", "04-Tests", "{65D03F19-3A88-4525-8BCC-67428B59EC10}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mews.Reusable.UnitTests", "Mews.Reusable.UnitTests\Mews.Reusable.UnitTests.csproj", "{4ACA6F59-6F8F-40DC-9769-68513F1A0367}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mews.ExchangeRates.Domain.UnitTests", "Mews.ExchangeRates.Domain.UnitTests\Mews.ExchangeRates.Domain.UnitTests.csproj", "{54E2C8ED-EC2F-4068-B1A5-C7C5680D1C6E}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "00-SolutionFiles", "00-SolutionFiles", "{9C903E0B-E319-46B4-BE3E-5EDD902D3B81}" + ProjectSection(SolutionItems) = preProject + .editorconfig = .editorconfig + EndProjectSection +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mews.CNBRateReader.IntegrationTests", "Mews.CNBRateReader.IntegrationTests\Mews.CNBRateReader.IntegrationTests.csproj", "{F8CA7550-3D0B-4261-9065-3EFC834FDFE4}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Mews.CzechNationalBankRateReader.UnitTests", "Mews.CzechNationalBankRateReader.UnitTests\Mews.CzechNationalBankRateReader.UnitTests.csproj", "{F80A9826-299B-443A-8B4D-A4F71092C102}" EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution @@ -11,12 +36,45 @@ Global Release|Any CPU = Release|Any CPU EndGlobalSection GlobalSection(ProjectConfigurationPlatforms) = postSolution - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Debug|Any CPU.Build.0 = Debug|Any CPU - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.ActiveCfg = Release|Any CPU - {7B2695D6-D24C-4460-A58E-A10F08550CE0}.Release|Any CPU.Build.0 = Release|Any CPU + {A4EF7F56-F273-45B5-80AD-87D95754F68C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {A4EF7F56-F273-45B5-80AD-87D95754F68C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {A4EF7F56-F273-45B5-80AD-87D95754F68C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {A4EF7F56-F273-45B5-80AD-87D95754F68C}.Release|Any CPU.Build.0 = Release|Any CPU + {C929BECF-D483-439C-AEA7-62EA492F090F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C929BECF-D483-439C-AEA7-62EA492F090F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C929BECF-D483-439C-AEA7-62EA492F090F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C929BECF-D483-439C-AEA7-62EA492F090F}.Release|Any CPU.Build.0 = Release|Any CPU + {80A71042-B220-4733-A5D9-0A98476DB9D2}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {80A71042-B220-4733-A5D9-0A98476DB9D2}.Debug|Any CPU.Build.0 = Debug|Any CPU + {80A71042-B220-4733-A5D9-0A98476DB9D2}.Release|Any CPU.ActiveCfg = Release|Any CPU + {80A71042-B220-4733-A5D9-0A98476DB9D2}.Release|Any CPU.Build.0 = Release|Any CPU + {4ACA6F59-6F8F-40DC-9769-68513F1A0367}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {4ACA6F59-6F8F-40DC-9769-68513F1A0367}.Debug|Any CPU.Build.0 = Debug|Any CPU + {4ACA6F59-6F8F-40DC-9769-68513F1A0367}.Release|Any CPU.ActiveCfg = Release|Any CPU + {4ACA6F59-6F8F-40DC-9769-68513F1A0367}.Release|Any CPU.Build.0 = Release|Any CPU + {54E2C8ED-EC2F-4068-B1A5-C7C5680D1C6E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {54E2C8ED-EC2F-4068-B1A5-C7C5680D1C6E}.Debug|Any CPU.Build.0 = Debug|Any CPU + {54E2C8ED-EC2F-4068-B1A5-C7C5680D1C6E}.Release|Any CPU.ActiveCfg = Release|Any CPU + {54E2C8ED-EC2F-4068-B1A5-C7C5680D1C6E}.Release|Any CPU.Build.0 = Release|Any CPU + {F8CA7550-3D0B-4261-9065-3EFC834FDFE4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F8CA7550-3D0B-4261-9065-3EFC834FDFE4}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F8CA7550-3D0B-4261-9065-3EFC834FDFE4}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F8CA7550-3D0B-4261-9065-3EFC834FDFE4}.Release|Any CPU.Build.0 = Release|Any CPU + {F80A9826-299B-443A-8B4D-A4F71092C102}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {F80A9826-299B-443A-8B4D-A4F71092C102}.Debug|Any CPU.Build.0 = Debug|Any CPU + {F80A9826-299B-443A-8B4D-A4F71092C102}.Release|Any CPU.ActiveCfg = Release|Any CPU + {F80A9826-299B-443A-8B4D-A4F71092C102}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {A4EF7F56-F273-45B5-80AD-87D95754F68C} = {28AE7DF2-75FF-4701-8D7E-2DCEBACB4300} + {C929BECF-D483-439C-AEA7-62EA492F090F} = {8A28BE11-C311-4822-8CC9-58103D85B90A} + {80A71042-B220-4733-A5D9-0A98476DB9D2} = {B7E7D836-B8B5-4F5B-B699-EB3AC6B0079C} + {4ACA6F59-6F8F-40DC-9769-68513F1A0367} = {65D03F19-3A88-4525-8BCC-67428B59EC10} + {54E2C8ED-EC2F-4068-B1A5-C7C5680D1C6E} = {65D03F19-3A88-4525-8BCC-67428B59EC10} + {F8CA7550-3D0B-4261-9065-3EFC834FDFE4} = {65D03F19-3A88-4525-8BCC-67428B59EC10} + {F80A9826-299B-443A-8B4D-A4F71092C102} = {65D03F19-3A88-4525-8BCC-67428B59EC10} + EndGlobalSection EndGlobal diff --git a/jobs/Backend/Task/Mews.CNBRateReader.IntegrationTests/ExchangeRateReaderTests.cs b/jobs/Backend/Task/Mews.CNBRateReader.IntegrationTests/ExchangeRateReaderTests.cs new file mode 100644 index 000000000..2cc9ef571 --- /dev/null +++ b/jobs/Backend/Task/Mews.CNBRateReader.IntegrationTests/ExchangeRateReaderTests.cs @@ -0,0 +1,45 @@ +using AutoFixture.Xunit2; +using Castle.Core.Logging; +using Mews.CzechNationalBankRateReader; +using Mews.ExchangeRates.Domain.Configuration; +using Mews.Reusable.UnitTests.Attributes; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using System.Globalization; + +namespace Mews.CNBRateReader.IntegrationTests +{ + public class ExchangeRateReaderTests + { + [Theory] + [InlineAutoNSubstituteData("CZK", + "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt")] + public async Task GetExchangeRatesAsync_RetrievesAListOfCurrencies(string expectedDestinationCurrencyCode, + string url, + [Frozen] IOptions options, + [Frozen] ILogger logger + ) + { + //Prepare + options.Value.Returns(new ExchangeRateOptions { SourceUri = url }); + var systemUnderTest = new ExchangeRateReader( + new HttpClient(), + new ResponseBodyParser( + new FirstLineParser(), + new ExchangeRateContentParser()), + options, + logger); + //Act + var result = await systemUnderTest.GetExchangeRatesAsync(); + + //Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.True(result.Count() > 5); + Assert.True(result.All(er => er.SourceCurrency.Code != expectedDestinationCurrencyCode)); + Assert.True(result.All(er => er.TargetCurrency.Code == expectedDestinationCurrencyCode)); + Assert.True(result.All(er => er.Value > 0m)); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.CNBRateReader.IntegrationTests/Mews.CNBRateReader.IntegrationTests.csproj b/jobs/Backend/Task/Mews.CNBRateReader.IntegrationTests/Mews.CNBRateReader.IntegrationTests.csproj new file mode 100644 index 000000000..0caa88653 --- /dev/null +++ b/jobs/Backend/Task/Mews.CNBRateReader.IntegrationTests/Mews.CNBRateReader.IntegrationTests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/ExchangeRateContentParserTests.cs b/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/ExchangeRateContentParserTests.cs new file mode 100644 index 000000000..f52fb9279 --- /dev/null +++ b/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/ExchangeRateContentParserTests.cs @@ -0,0 +1,44 @@ +using Mews.Reusable.UnitTests; +using Mews.Reusable.UnitTests.Attributes; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +namespace Mews.CzechNationalBankRateReader.UnitTests +{ + public class ExchangeRateContentParserTests + { + [Theory] + [InlineAutoNSubstituteData("ExchangeRatesValidContent.txt", "CZK", 31)] + public void ParseContent_returns_ok(string contentFileName, + string expectedDestinationCurrencyCode, + int expectedCount, + ExchangeRateContentParser systemUnderTest) + { + var content = EmbeddedResourceFileReader.ReadFileContent(Assembly.GetExecutingAssembly(), contentFileName); + var result = systemUnderTest.ParseContent(content); + Assert.NotNull(result); + Assert.True(result.Count() == expectedCount); + Assert.True(result.All(er => !string.IsNullOrWhiteSpace(er.Country))); + Assert.True(result.All(er => !string.IsNullOrWhiteSpace(er.Currency))); + Assert.True(result.All(er => er.Amount > 0)); + Assert.True(result.All(er => er.Code != expectedDestinationCurrencyCode && er.Code != null)); + Assert.True(result.All(er => er.Rate > 0m)); + } + + [Theory] + [InlineAutoNSubstituteData("ExchangeRatesInValidContent.txt")] + public void ParseContent_returns_nok(string contentFileName, + ExchangeRateContentParser systemUnderTest) + { + var content = EmbeddedResourceFileReader.ReadFileContent(Assembly.GetExecutingAssembly(), contentFileName); + //Act + var myAction = () => systemUnderTest.ParseContent(content); + //Assert + Assert.Throws(myAction); + } + } +} diff --git a/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/ExchangeRateReaderTests.cs b/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/ExchangeRateReaderTests.cs new file mode 100644 index 000000000..d19eedf17 --- /dev/null +++ b/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/ExchangeRateReaderTests.cs @@ -0,0 +1,158 @@ +using AutoFixture.Xunit2; +using Mews.CzechNationalBankRateReader.Interfaces; +using Mews.CzechNationalBankRateReader.Models; +using Mews.ExchangeRates.Domain.Configuration; +using Mews.ExchangeRates.Domain.Exceptions; +using Mews.Reusable.UnitTests.Attributes; +using Mews.CzechNationalBankRateReader.UnitTests.Mocks; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading.Tasks; +using Xunit.Sdk; + +namespace Mews.CzechNationalBankRateReader.UnitTests +{ + public class ExchangeRateReaderTests + { + const string _validUri = "https://localhost"; + + [Theory] + [InlineAutoNSubstituteData(HttpStatusCode.BadRequest)] + [InlineAutoNSubstituteData(HttpStatusCode.Unauthorized)] + [InlineAutoNSubstituteData(HttpStatusCode.InternalServerError)] + public async Task GetExchangeRates_Throws_When_ResponseHttpCodeIsError( + HttpStatusCode responseCode, + string responseBody, + [Frozen] IResponseBodyParser responseBodyParser, + [Frozen] IOptions options, + [Frozen] ILogger logger + ) + { + //Prepare + options.Value.Returns(new ExchangeRateOptions { SourceUri = _validUri }); + var client = new HttpClient(new CustomHttpClientHandler(responseCode,responseBody)); + var systemUnderTest= new ExchangeRateReader(client, responseBodyParser, options, logger ); + + //Act + var GetExchangeRatesAction = async () => await systemUnderTest.GetExchangeRatesAsync(); + var exception = await Assert.ThrowsAsync(GetExchangeRatesAction); + + //Assert + Assert.Contains(responseCode.ToString(),exception.Message); + } + + [Theory] + [InlineAutoNSubstituteData(HttpStatusCode.OK)] + public async Task GetExchangeRates_Throws_When_ResponseBodyParserThrows( + HttpStatusCode responseCode, + string responseBody, + string exceptionTestMessage, + [Frozen] IResponseBodyParser responseBodyParser, + [Frozen] IOptions options, + [Frozen] ILogger logger + ) + { + //Prepare + options.Value.Returns(new ExchangeRateOptions { SourceUri = _validUri }); + responseBodyParser.ParseBody(responseBody).Throws(new Exception(exceptionTestMessage)); + var client = new HttpClient(new CustomHttpClientHandler(responseCode, responseBody)); + var systemUnderTest = new ExchangeRateReader(client, responseBodyParser, options, logger); + + //Act + var GetExchangeRatesAction = async () => await systemUnderTest.GetExchangeRatesAsync(); + var exception = await Assert.ThrowsAnyAsync(GetExchangeRatesAction); + + //Assert + Assert.Contains(exceptionTestMessage, exception.Message); + } + [Theory] + [InlineAutoNSubstituteData(HttpStatusCode.OK)] + public async Task GetExchangeRates_Throws_When_NoExchangeRatesParsed( + HttpStatusCode responseCode, + string responseBody, + CentralBankResponse parsedResponse, + [Frozen] IResponseBodyParser responseBodyParser, + [Frozen] IOptions options, + [Frozen] ILogger logger + ) + { + //Prepare + options.Value.Returns(new ExchangeRateOptions { SourceUri = _validUri }); + parsedResponse.ExchangeRates = null; + responseBodyParser.ParseBody(responseBody).Returns(parsedResponse); + var client = new HttpClient(new CustomHttpClientHandler(responseCode, responseBody)); + var systemUnderTest = new ExchangeRateReader(client, responseBodyParser, options, logger); + + //Act + var GetExchangeRatesAction = async () => await systemUnderTest.GetExchangeRatesAsync(); + var exception = await Assert.ThrowsAsync(GetExchangeRatesAction); + + //Assert + Assert.NotEmpty(exception.Message); + } + [Theory] + [InlineAutoNSubstituteData(HttpStatusCode.OK)] + public async Task GetExchangeRates_Throws_When_NoMetadataParsed( + HttpStatusCode responseCode, + string responseBody, + CentralBankResponse parsedResponse, + [Frozen] IResponseBodyParser responseBodyParser, + [Frozen] IOptions options, + [Frozen] ILogger logger + ) + { + //Prepare + options.Value.Returns(new ExchangeRateOptions { SourceUri = _validUri }); + parsedResponse.Metadata = null; + responseBodyParser.ParseBody(responseBody).Returns(parsedResponse); + var client = new HttpClient(new CustomHttpClientHandler(responseCode, responseBody)); + var systemUnderTest = new ExchangeRateReader(client, responseBodyParser, options, logger); + + //Act + var GetExchangeRatesAction = async () => await systemUnderTest.GetExchangeRatesAsync(); + var exception = await Assert.ThrowsAsync(GetExchangeRatesAction); + + //Assert + Assert.NotEmpty(exception.Message); + } + + [Theory] + [InlineAutoNSubstituteData(HttpStatusCode.OK)] + public async Task GetExchangeRates_ReturnsList_When_RatesParsed( + HttpStatusCode responseCode, + string responseBody, + CentralBankResponse parsedResponse, + [Frozen] IResponseBodyParser responseBodyParser, + [Frozen] IOptions options, + [Frozen] ILogger logger + ) + { + //Prepare + options.Value.Returns(new ExchangeRateOptions { SourceUri = _validUri }); + responseBodyParser.ParseBody(responseBody).Returns(parsedResponse); + var client = new HttpClient(new CustomHttpClientHandler(responseCode, responseBody)); + var systemUnderTest = new ExchangeRateReader(client, responseBodyParser, options, logger); + + //Act + var result = await systemUnderTest.GetExchangeRatesAsync(); + + //Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.True(result.Count() == parsedResponse!.ExchangeRates!.Count()); + var parsedCurrencyCodes = parsedResponse!.ExchangeRates!.Select(per => per.Code); + Assert.True(result.All(er => parsedCurrencyCodes.Contains(er.SourceCurrency.Code))); + Assert.True(result.All(er => er.SourceCurrency.Code != ExchangeRateReader.TargetCurrencyCode)); + Assert.True(result.All(er => er.TargetCurrency.Code == ExchangeRateReader.TargetCurrencyCode)); + Assert.True(result.All(er => er.Value != 0m)); + + } + } +} diff --git a/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/ExchangeRateSaverTests.cs b/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/ExchangeRateSaverTests.cs new file mode 100644 index 000000000..278992061 --- /dev/null +++ b/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/ExchangeRateSaverTests.cs @@ -0,0 +1,31 @@ +using AutoFixture.Xunit2; +using Mews.ExchangeRates.Domain; +using Mews.ExchangeRates.Domain.Configuration; +using Mews.Reusable.UnitTests.Attributes; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using NSubstitute; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Mews.CzechNationalBankRateReader.UnitTests; +public class ExchangeRateSaverTests +{ + [Theory, AutoNSubstituteData] + public async Task SavesAndReadsCorrecly( + List data, + [Frozen] IOptions options, + ILogger logger) + { + var config = new ExchangeRateOptions(); + config.DataFilePath = "data/ExchangeRateSaverTests.json"; + options.Value.Returns(config); + ExchangeRateSaver systemUnderTest = new ExchangeRateSaver(options, logger); + await systemUnderTest.SaveExchangeRatesAsync(data); + var read = await systemUnderTest.GetExchangeRatesAsync(); + Assert.True(data.SequenceEqual(read)); + } +} diff --git a/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/FirstLineParserTests.cs b/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/FirstLineParserTests.cs new file mode 100644 index 000000000..61f1aeea5 --- /dev/null +++ b/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/FirstLineParserTests.cs @@ -0,0 +1,56 @@ +using Mews.Reusable.UnitTests.Attributes; + +namespace Mews.CzechNationalBankRateReader.UnitTests +{ + public class FirstLineParserTests + { + [Theory] + [InlineAutoNSubstituteData("24 Jan 2025 #17", "2025-01-24", 17)] + [InlineAutoNSubstituteData("2 Feb 2021 # 12", "2021-02-02", 12)] + [InlineAutoNSubstituteData("29 Feb 2020 # 15", "2020-02-29", 15)] + [InlineAutoNSubstituteData("5 Mar 2000 #29", "2000-03-05", 29)] + [InlineAutoNSubstituteData("7 Apr 2100 # 19", "2100-04-07", 19)] + [InlineAutoNSubstituteData("24 May 2025 #22", "2025-05-24", 22)] + [InlineAutoNSubstituteData("20 Jun 2121 # 120", "2121-06-20", 120)] + [InlineAutoNSubstituteData("4 Jul 2025 #4", "2025-07-04", 4)] + [InlineAutoNSubstituteData("24 Dec 2125 #97", "2125-12-24", 97)] + public void ParseFirstLine_ParsesFirstRecord_WhenValid(string firstRecord, string expectedDate, int expectedSequence, + FirstLineParser systemUndeTest) + { + var parsedRecord = systemUndeTest.ParseFirstLine(firstRecord); + Assert.True(parsedRecord != null); + Assert.True(DateOnly.Parse(expectedDate).Equals(parsedRecord.Date)); + Assert.Equal(expectedSequence, parsedRecord.YearlySequence); + } + + [Theory] + #region invalid dates + [InlineAutoNSubstituteData("29 Feb 2021 # 15")] + [InlineAutoNSubstituteData("5 Martes 2000 #29")] + [InlineAutoNSubstituteData("7 Abrigo 2100 # 19")] + [InlineAutoNSubstituteData("34 May 2025 #22")] + [InlineAutoNSubstituteData("31 Jun 2121 # 120")] + [InlineAutoNSubstituteData("4 JulyAug 2025 #4")] + [InlineAutoNSubstituteData("22 Dic 2125 #97")] + #endregion + #region No # present to split content + [InlineAutoNSubstituteData("29 Feb 2021 15")] + [InlineAutoNSubstituteData("5 Martes 2000")] + [InlineAutoNSubstituteData("4 Jul -2025 4")] + #endregion + [InlineAutoNSubstituteData("# 19")] + [InlineAutoNSubstituteData("30 May 2025 #22.1")] + #region incorrect number + [InlineAutoNSubstituteData("30 Jun 2121 # 12s0")] + [InlineAutoNSubstituteData("24 Dec 2125 #aa")] + #endregion + public void ParseFirstLine_Throws_WhenFirstRecordInValid(string firstRecord, FirstLineParser systemUndeTest) + { + var parseFirstLineAction = () => systemUndeTest.ParseFirstLine(firstRecord); + Assert.Throws(parseFirstLineAction); + } + + + + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/Mews.CzechNationalBankRateReader.UnitTests.csproj b/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/Mews.CzechNationalBankRateReader.UnitTests.csproj new file mode 100644 index 000000000..47e527f1f --- /dev/null +++ b/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/Mews.CzechNationalBankRateReader.UnitTests.csproj @@ -0,0 +1,47 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + Always + + + Always + + + + + + + + + + + + + + + + + + + + + + Always + + + + diff --git a/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/Mocks/CustomHttpClientHandler.cs b/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/Mocks/CustomHttpClientHandler.cs new file mode 100644 index 000000000..3ee075964 --- /dev/null +++ b/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/Mocks/CustomHttpClientHandler.cs @@ -0,0 +1,20 @@ +using System.Net; +using System.Text; + +namespace Mews.CzechNationalBankRateReader.UnitTests.Mocks +{ + /// + /// This class is used to mock HttpClient in unit tests. + /// + public class CustomHttpClientHandler(HttpStatusCode returnCode, string content) : HttpMessageHandler + { + protected override Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + var response = new HttpResponseMessage(returnCode) + { + Content = new StringContent(content, Encoding.UTF8) + }; + return Task.FromResult(response); + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/Resources/ExchangeRatesInValidContent.txt b/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/Resources/ExchangeRatesInValidContent.txt new file mode 100644 index 000000000..bc75b65ca --- /dev/null +++ b/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/Resources/ExchangeRatesInValidContent.txt @@ -0,0 +1,32 @@ +Country,Currency,Amount,Code,Rate +Australia|dollar|1|AUD|15.118 +Brazil|real|1|BRL|4.062 +Bulgaria|lev|1|BGN|12.829 +Canada|dollar|1|CAD|16.717 +China|renminbi|1|CNY|3.306 +Denmark|krone|1|DKK|3.363 +EMU|euro|1|EUR|25.095 +Hongkong|dollar|1|HKD|3.076 +Hungary|forint|100|HUF|6.137 +Iceland|krona|100|ISK|17.153 +IMF|SDR|1|XDR|31.223 +India|rupee|100|INR|27.771 +Indonesia|rupiah|1000|IDR|1.481 +Israel|new shekel|1|ILS|6.675 +Japan|yen|100|JPY|15.313 +Malaysia|ringgit|1|MYR|5.473 +Mexico|peso|1|MXN|1.186 +New Zealand|dollar|1|NZD|13.667 +Norway|krone|1|NOK|2.136 +Philippines|peso|100|PHP|41.033 +Poland|zloty|1|PLN|5.956 +Romania|leu|1|RON|5.043 +Singapore|dollar|1|SGD|17.759 +South Africa|rand|1|ZAR|1.304 +South Korea|won|100|KRW|1.671 +Sweden|krona|1|SEK|2.191 +Switzerland|franc|1|CHF|26.432 +Thailand|baht|100|THB|71.146 +Turkey|lira|100|TRY|67.126 +United Kingdom|pound|1|GBP|29.730 +USA|dollar|1|USD|23.958 diff --git a/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/Resources/ExchangeRatesValidContent.txt b/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/Resources/ExchangeRatesValidContent.txt new file mode 100644 index 000000000..bedebb65c --- /dev/null +++ b/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/Resources/ExchangeRatesValidContent.txt @@ -0,0 +1,32 @@ +Country|Currency|Amount|Code|Rate +Australia|dollar|1|AUD|15.118 +Brazil|real|1|BRL|4.062 +Bulgaria|lev|1|BGN|12.829 +Canada|dollar|1|CAD|16.717 +China|renminbi|1|CNY|3.306 +Denmark|krone|1|DKK|3.363 +EMU|euro|1|EUR|25.095 +Hongkong|dollar|1|HKD|3.076 +Hungary|forint|100|HUF|6.137 +Iceland|krona|100|ISK|17.153 +IMF|SDR|1|XDR|31.223 +India|rupee|100|INR|27.771 +Indonesia|rupiah|1000|IDR|1.481 +Israel|new shekel|1|ILS|6.675 +Japan|yen|100|JPY|15.313 +Malaysia|ringgit|1|MYR|5.473 +Mexico|peso|1|MXN|1.186 +New Zealand|dollar|1|NZD|13.667 +Norway|krone|1|NOK|2.136 +Philippines|peso|100|PHP|41.033 +Poland|zloty|1|PLN|5.956 +Romania|leu|1|RON|5.043 +Singapore|dollar|1|SGD|17.759 +South Africa|rand|1|ZAR|1.304 +South Korea|won|100|KRW|1.671 +Sweden|krona|1|SEK|2.191 +Switzerland|franc|1|CHF|26.432 +Thailand|baht|100|THB|71.146 +Turkey|lira|100|TRY|67.126 +United Kingdom|pound|1|GBP|29.730 +USA|dollar|1|USD|23.958 diff --git a/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/ResponseBodyParserTests.cs b/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/ResponseBodyParserTests.cs new file mode 100644 index 000000000..e5e52b80d --- /dev/null +++ b/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/ResponseBodyParserTests.cs @@ -0,0 +1,98 @@ +using AutoFixture.Xunit2; +using Mews.CzechNationalBankRateReader.Exceptions; +using Mews.CzechNationalBankRateReader.Interfaces; +using Mews.CzechNationalBankRateReader.Models; +using Mews.ExchangeRates.Domain; +using Mews.Reusable.UnitTests.Attributes; +using NSubstitute; +using NSubstitute.ExceptionExtensions; + +namespace Mews.CzechNationalBankRateReader.UnitTests +{ + public class ResponseBodyParserTests + { + [Theory] + [InlineAutoNSubstituteData()] + public void ParseBody_IsSuccess_WhenValidContent( + //Data + string firstLine, + string followingLines, + //Mock data + List exchangeRates, + FirstRecord firstRecord, + //Mocks + [Frozen]IFirstLineParser firstLineParser, + [Frozen]IExchangeRateContentParser contentParser, + //Target of the test + ResponseBodyParser systemUndeTest) + { + //Prepare + contentParser.ParseContent(Arg.Any()).Returns(exchangeRates); + firstLineParser.ParseFirstLine(Arg.Any()).Returns(firstRecord); + + //Act + var parsedBody = systemUndeTest.ParseBody(string.Concat(firstLine, ResponseBodyParser.EndOfLineChar, followingLines)); + + //Assert + Assert.False(parsedBody is null); + Assert.False(parsedBody.Metadata is null); + Assert.False(parsedBody.ExchangeRates is null); + Assert.True(firstRecord.Date.Equals(parsedBody.Metadata.Date)); + Assert.Equal(firstRecord.YearlySequence,parsedBody.Metadata.YearlySequence); + Assert.Equal(exchangeRates.Count, parsedBody.ExchangeRates.Count()); + } + + [Theory] + [InlineAutoNSubstituteData()] + public void ParseBody_Throws_WhenThereIsNoEndOfLine(string body, + ResponseBodyParser systemUndeTest) + { + //Check data pre-condition for this test case + Assert.True(body.IndexOf(ResponseBodyParser.EndOfLineChar) < 0); + + //Prepare + var myTestAction = () => systemUndeTest.ParseBody(body); + //Act + var exception = Assert.Throws(myTestAction); + //Assert + Assert.Contains(body,exception.Message); + } + + + + [Theory] + [InlineAutoNSubstituteData()] + public void ParseBody_Throws_WhenFirstRecordInValid(string firstLine, + string followingLines, + [Frozen] IFirstLineParser firstLineParser, + ResponseBodyParser systemUndeTest) + { + //Prepare + firstLineParser.ParseFirstLine(firstLine).Throws(new FormatException("mocked in unit test")); + //Act + var myTestAction = () => systemUndeTest.ParseBody(string.Concat(firstLine, ResponseBodyParser.EndOfLineChar, followingLines)); + //Assert + var exception = Assert.Throws(myTestAction); + } + + [Theory] + [InlineAutoNSubstituteData()] + public void ParseBody_Throws_WhenContentInValid(string firstLine, + string followingLines, + FirstRecord firstRecord, + [Frozen] IFirstLineParser firstLineParser, + [Frozen] IExchangeRateContentParser contentParser, + ResponseBodyParser systemUndeTest) + { + //Prepare + contentParser.ParseContent(Arg.Any()).Throws(new FormatException("mocked in unit test")); + firstLineParser.ParseFirstLine(Arg.Any()).Returns(firstRecord); + //Act + var myTestAction = () => systemUndeTest.ParseBody(string.Concat(firstLine, ResponseBodyParser.EndOfLineChar, followingLines)); + //Assert + var exception = Assert.Throws(myTestAction); + } + + + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/data/ForceFolderCreation.txt b/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/data/ForceFolderCreation.txt new file mode 100644 index 000000000..420530f8c --- /dev/null +++ b/jobs/Backend/Task/Mews.CzechNationalBankRateReader.UnitTests/data/ForceFolderCreation.txt @@ -0,0 +1 @@ +This file will guarantee that data folder is created \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.CzechNationalBankRateReader/Exceptions/InvalidBodyException.cs b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/Exceptions/InvalidBodyException.cs new file mode 100644 index 000000000..306cf6b31 --- /dev/null +++ b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/Exceptions/InvalidBodyException.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Mews.CzechNationalBankRateReader.Exceptions +{ + public class InvalidBodyException(string body): Exception($"The body is {body}"); +} diff --git a/jobs/Backend/Task/Mews.CzechNationalBankRateReader/ExchangeRateContentParser.cs b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/ExchangeRateContentParser.cs new file mode 100644 index 000000000..e0a04be46 --- /dev/null +++ b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/ExchangeRateContentParser.cs @@ -0,0 +1,25 @@ +using CsvHelper.Configuration; +using CsvHelper; +using Mews.CzechNationalBankRateReader.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Mews.CzechNationalBankRateReader.Interfaces; + +namespace Mews.CzechNationalBankRateReader +{ + public class ExchangeRateContentParser: IExchangeRateContentParser + { + public IEnumerable ParseContent(string content) + { + var config = CsvConfiguration.FromAttributes(); + using var reader = new StringReader(content); + using var csv = new CsvReader(reader, config); + var records = csv.GetRecords(); + // records need to be read before csv (Reader) will be disposed by using statement + return records.ToList(); + } + } +} diff --git a/jobs/Backend/Task/Mews.CzechNationalBankRateReader/ExchangeRateReader.cs b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/ExchangeRateReader.cs new file mode 100644 index 000000000..525c952d5 --- /dev/null +++ b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/ExchangeRateReader.cs @@ -0,0 +1,51 @@ +using Mews.CzechNationalBankRateReader.Interfaces; +using Mews.ExchangeRates.Domain; +using Mews.ExchangeRates.Domain.Configuration; +using Mews.ExchangeRates.Domain.Contracts; +using Mews.ExchangeRates.Domain.Exceptions; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System.Data; +using System.Runtime.InteropServices.Marshalling; +using System.Security.Cryptography.X509Certificates; + +namespace Mews.CzechNationalBankRateReader +{ + public class ExchangeRateReader(HttpClient httpClient, + IResponseBodyParser responseBodyParser, + IOptions options, + ILogger logger) + : IExchangeRateReader + { + public const string TargetCurrencyCode = "CZK"; + private string _sourceUri = options.Value.SourceUri; + + public async Task> GetExchangeRatesAsync() + { + logger.LogInformation("Reading Exchange Rates from {sourceUri}",_sourceUri); + var request = new HttpRequestMessage(HttpMethod.Get, _sourceUri); + var response = await httpClient.SendAsync(request); + if (!response.IsSuccessStatusCode) + { + throw new DataReadException($"Reading exchange rates returned {response.StatusCode}"); + } + var body = await response.Content.ReadAsStringAsync(); + logger.LogDebug("read data: {body}", body); + var rates = responseBodyParser.ParseBody(body); + if(rates.ExchangeRates is null) + { + throw new DataReadException($"No exchange rates could be parsed."); + } + if (rates.Metadata is null) + { + throw new DataReadException($"No Metadata could be parsed."); + } + var targetCurrency = new Currency(TargetCurrencyCode); + return rates.ExchangeRates.Select(er => new ExchangeRate( + new Currency(er.Code), + targetCurrency, + er.Rate / er.Amount, + rates.Metadata.Date)); + } + } +} diff --git a/jobs/Backend/Task/Mews.CzechNationalBankRateReader/ExchangeRateSaver.cs b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/ExchangeRateSaver.cs new file mode 100644 index 000000000..d2e7a5318 --- /dev/null +++ b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/ExchangeRateSaver.cs @@ -0,0 +1,38 @@ +using Mews.ExchangeRates.Domain; +using Mews.ExchangeRates.Domain.Configuration; +using Mews.ExchangeRates.Domain.Contracts; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Text.Json; +using System.Threading.Tasks; + +namespace Mews.CzechNationalBankRateReader; +public class ExchangeRateSaver(IOptions options, ILogger logger) : IExchangeRateSaver +{ + private static readonly Encoding _encoding = Encoding.UTF8; + private static readonly JsonSerializerOptions _jsonSerializerOptions = new() { WriteIndented = true }; + private readonly string _fileLocation = options.Value.DataFilePath; + + public async Task> GetExchangeRatesAsync() + { + if(!File.Exists(this._fileLocation)) + return []; + logger.LogDebug("Reading Data From {fileLocation}", _fileLocation); + var readContentFile = await File.ReadAllTextAsync(_fileLocation, _encoding); + if(string.IsNullOrWhiteSpace(readContentFile)) + return []; + return JsonSerializer.Deserialize>(readContentFile!) ?? []; + } + + public async Task SaveExchangeRatesAsync(IEnumerable exchangeRates) + { + logger.LogInformation("Saving Data To {fileLocation}",_fileLocation); + var data = JsonSerializer.Serialize(exchangeRates, _jsonSerializerOptions); + await File.WriteAllTextAsync(_fileLocation, data, _encoding); + } + +} diff --git a/jobs/Backend/Task/Mews.CzechNationalBankRateReader/FirstLineParser.cs b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/FirstLineParser.cs new file mode 100644 index 000000000..2368b1066 --- /dev/null +++ b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/FirstLineParser.cs @@ -0,0 +1,24 @@ +using Mews.CzechNationalBankRateReader.Interfaces; +using Mews.CzechNationalBankRateReader.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Mews.CzechNationalBankRateReader +{ + public class FirstLineParser : IFirstLineParser + { + public FirstRecord ParseFirstLine(string firstLine) + { + var firstLineValues = firstLine.Split('#'); + var firstRecord = new FirstRecord + { + Date = DateOnly.Parse(firstLineValues[0]), + YearlySequence = int.Parse(firstLineValues[1]), + }; + return firstRecord; + } + } +} diff --git a/jobs/Backend/Task/Mews.CzechNationalBankRateReader/Interfaces/IExchangeRateContentParser.cs b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/Interfaces/IExchangeRateContentParser.cs new file mode 100644 index 000000000..b3a823c80 --- /dev/null +++ b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/Interfaces/IExchangeRateContentParser.cs @@ -0,0 +1,9 @@ +using Mews.CzechNationalBankRateReader.Models; + +namespace Mews.CzechNationalBankRateReader.Interfaces +{ + public interface IExchangeRateContentParser + { + IEnumerable ParseContent(string content); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.CzechNationalBankRateReader/Interfaces/IFirstLineParser.cs b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/Interfaces/IFirstLineParser.cs new file mode 100644 index 000000000..1d4fdfb64 --- /dev/null +++ b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/Interfaces/IFirstLineParser.cs @@ -0,0 +1,14 @@ +using Mews.CzechNationalBankRateReader.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Mews.CzechNationalBankRateReader.Interfaces +{ + public interface IFirstLineParser + { + FirstRecord ParseFirstLine(string firstLine); + } +} diff --git a/jobs/Backend/Task/Mews.CzechNationalBankRateReader/Interfaces/IResponseBodyParser.cs b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/Interfaces/IResponseBodyParser.cs new file mode 100644 index 000000000..273c38b5a --- /dev/null +++ b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/Interfaces/IResponseBodyParser.cs @@ -0,0 +1,9 @@ +using Mews.CzechNationalBankRateReader.Models; + +namespace Mews.CzechNationalBankRateReader.Interfaces +{ + public interface IResponseBodyParser + { + CentralBankResponse ParseBody(string body); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.CzechNationalBankRateReader/Mews.CzechNationalBankRateReader.csproj b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/Mews.CzechNationalBankRateReader.csproj new file mode 100644 index 000000000..8e247bd84 --- /dev/null +++ b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/Mews.CzechNationalBankRateReader.csproj @@ -0,0 +1,18 @@ + + + + net8.0 + enable + enable + + + + + + + + + + + + diff --git a/jobs/Backend/Task/Mews.CzechNationalBankRateReader/Models/CentralBankExchangeRate.cs b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/Models/CentralBankExchangeRate.cs new file mode 100644 index 000000000..3de2e696d --- /dev/null +++ b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/Models/CentralBankExchangeRate.cs @@ -0,0 +1,20 @@ +using CsvHelper.Configuration.Attributes; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Mews.CzechNationalBankRateReader.Models +{ + [Delimiter("|")] + [CultureInfo("en-US")] + public class CentralBankExchangeRate + { + public string Country { get; set; } = null!; + public string Currency { get; set; } = null!; + public int Amount { get; set; } + public string Code { get; set; } = null!; + public decimal Rate { get; set; } + } +} diff --git a/jobs/Backend/Task/Mews.CzechNationalBankRateReader/Models/CentralBankResponse.cs b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/Models/CentralBankResponse.cs new file mode 100644 index 000000000..3482bd971 --- /dev/null +++ b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/Models/CentralBankResponse.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Mews.CzechNationalBankRateReader.Models +{ + public class CentralBankResponse + { + public FirstRecord? Metadata { get; set; } + public IEnumerable? ExchangeRates { get; set; } + } +} diff --git a/jobs/Backend/Task/Mews.CzechNationalBankRateReader/Models/FirstRecord.cs b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/Models/FirstRecord.cs new file mode 100644 index 000000000..3fb56c41c --- /dev/null +++ b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/Models/FirstRecord.cs @@ -0,0 +1,14 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Mews.CzechNationalBankRateReader.Models +{ + public record FirstRecord + { + public DateOnly Date { get; set; } + public int YearlySequence { get; set; } + } +} diff --git a/jobs/Backend/Task/Mews.CzechNationalBankRateReader/ResponseBodyParser.cs b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/ResponseBodyParser.cs new file mode 100644 index 000000000..241d31d8c --- /dev/null +++ b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/ResponseBodyParser.cs @@ -0,0 +1,35 @@ +using CsvHelper.Configuration; +using CsvHelper; +using Mews.CzechNationalBankRateReader.Exceptions; +using Mews.CzechNationalBankRateReader.Models; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Mews.CzechNationalBankRateReader.Interfaces; + +namespace Mews.CzechNationalBankRateReader +{ + public class ResponseBodyParser(IFirstLineParser firstLineParser, IExchangeRateContentParser contentParser): IResponseBodyParser + { + public const string EndOfLineChar="\n"; + + public CentralBankResponse ParseBody(string body) + { + + int endOfFirstLine = body.IndexOf(EndOfLineChar); + if (endOfFirstLine < 0) + { + throw new InvalidBodyException(body); + } + string firstLine = body.Substring(0, endOfFirstLine); + var result = new CentralBankResponse + { + Metadata = firstLineParser.ParseFirstLine(firstLine), + ExchangeRates = contentParser.ParseContent(body.Substring(endOfFirstLine)) + }; + return result; + } + } +} diff --git a/jobs/Backend/Task/Mews.CzechNationalBankRateReader/ServiceCollectionExtensions.cs b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..527eb404a --- /dev/null +++ b/jobs/Backend/Task/Mews.CzechNationalBankRateReader/ServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +using Mews.CzechNationalBankRateReader.Interfaces; +using Mews.ExchangeRates.Domain.Contracts; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; + +namespace Mews.CzechNationalBankRateReader +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddExchangeRateReader(this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.TryAddSingleton(); + + return services; + } + } +} diff --git a/jobs/Backend/Task/Mews.ExchangeRateUpdater.App/Mews.ExchangeRateUpdater.App.csproj b/jobs/Backend/Task/Mews.ExchangeRateUpdater.App/Mews.ExchangeRateUpdater.App.csproj new file mode 100644 index 000000000..aba0caeb8 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateUpdater.App/Mews.ExchangeRateUpdater.App.csproj @@ -0,0 +1,37 @@ + + + + Exe + net8.0 + enable + true + + + + + + + + + + + + + + + + + + + + + + + + Always + + + Always + + + \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.ExchangeRateUpdater.App/Program.cs b/jobs/Backend/Task/Mews.ExchangeRateUpdater.App/Program.cs new file mode 100644 index 000000000..a56b55a93 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateUpdater.App/Program.cs @@ -0,0 +1,30 @@ +using Mews.ExchangeRates.Domain; +using Mews.ExchangeRates.Domain.Configuration; +using Mews.ExchangeRateUpdater.App; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Serilog.Extensions.Hosting; +using Serilog; +using Serilog.Settings.Configuration; +using Mews.CzechNationalBankRateReader; + +var builder = Host.CreateDefaultBuilder(args); + +builder.ConfigureServices( + (context,services) => + { + services.AddHostedService(); + services.AddOptions() + .Bind(context.Configuration.GetSection(nameof(ExchangeRateOptions))) + .ValidateDataAnnotations(); + services.AddExchangeRatesDomain(); + services.AddExchangeRateReader(); + }); + +builder.UseSerilog((ctx, config) => config.ReadFrom.Configuration(ctx.Configuration)); +IHost host = builder.Build(); + +await host.RunAsync(); + + diff --git a/jobs/Backend/Task/Mews.ExchangeRateUpdater.App/Worker.cs b/jobs/Backend/Task/Mews.ExchangeRateUpdater.App/Worker.cs new file mode 100644 index 000000000..fab465005 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateUpdater.App/Worker.cs @@ -0,0 +1,45 @@ +using Mews.ExchangeRates.Domain; +using Mews.ExchangeRates.Domain.Configuration; +using Mews.ExchangeRates.Domain.Contracts; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; + +namespace Mews.ExchangeRateUpdater.App +{ + public class Worker(IExchangeRateProvider provider, + IHostApplicationLifetime hostApplicationLifetime, + IOptions options, + ILogger logger) : IHostedService + { + private readonly IExchangeRateProvider _provider = provider; + private readonly IHostApplicationLifetime _hostApplicationLifetime = hostApplicationLifetime; + private readonly ILogger _logger = logger; + private readonly ExchangeRateOptions _options = options.Value; + + public async Task StartAsync(CancellationToken cancellationToken) + { + _logger.LogInformation(nameof(StartAsync)); + var configuredCurrencies = _options.Currencies.Select(c => new Currency(c)); + await DisplayExchangeRates(configuredCurrencies); + _hostApplicationLifetime.StopApplication(); + } + + private async Task DisplayExchangeRates(IEnumerable currencies) + { + var currencyRates = await _provider.GetExchangeRatesAsync(currencies); + foreach (var rate in currencyRates) + { + //We may need to decouple domain from presentation if ExchangeRate.ToString is not good enough + Console.WriteLine(rate.ToString()); + } + } + +#pragma warning disable CS1998 // Async method lacks 'await' operators and will run synchronously + public async Task StopAsync(CancellationToken cancellationToken) + { + _logger.LogDebug(nameof(StopAsync)); + } +#pragma warning restore CS1998 // Async method lacks 'await' operators and will run synchronously + } +} diff --git a/jobs/Backend/Task/Mews.ExchangeRateUpdater.App/appsettings.json b/jobs/Backend/Task/Mews.ExchangeRateUpdater.App/appsettings.json new file mode 100644 index 000000000..36eb7261a --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateUpdater.App/appsettings.json @@ -0,0 +1,40 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft": "Information", + "Microsoft.Hosting.Lifetime": "Warning" + } + }, + "Serilog": { + "Using": [ "Serilog.Sinks.File" ], + "MinimumLevel": "Debug", + "WriteTo": [ + { + "Name": "File", + "Args": { + "Path": "logs/ExchangeRateUpdater.log", + "RollingInterval": "Minute" + } + } + ], + "Properties": { + "Application": "ExchangeRateUpdater" + } + }, + "ExchangeRateOptions": { + "Currencies": [ + "USD", + "EUR", + "CZK", + "JPY", + "KES", + "RUB", + "THB", + "TRY", + "XYZ" + ], + "SourceUri": "https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt", + "DataFilePath": "data/PersistedExchangeRates.json" + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.ExchangeRateUpdater.App/data/PersistedExchangeRates.json b/jobs/Backend/Task/Mews.ExchangeRateUpdater.App/data/PersistedExchangeRates.json new file mode 100644 index 000000000..b9c310a7e --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRateUpdater.App/data/PersistedExchangeRates.json @@ -0,0 +1,312 @@ +[ + { + "SourceCurrency": { + "Code": "AUD" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 15.118, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "BRL" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 4.062, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "BGN" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 12.829, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "CAD" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 16.717, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "CNY" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 3.306, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "DKK" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 3.363, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "EUR" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 25.095, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "HKD" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 3.076, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "HUF" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 0.06137, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "ISK" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 0.17153, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "XDR" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 31.223, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "INR" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 0.27771, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "IDR" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 0.001481, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "ILS" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 6.675, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "JPY" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 0.15313, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "MYR" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 5.473, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "MXN" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 1.186, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "NZD" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 13.667, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "NOK" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 2.136, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "PHP" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 0.41033, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "PLN" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 5.956, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "RON" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 5.043, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "SGD" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 17.759, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "ZAR" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 1.304, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "KRW" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 0.01671, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "SEK" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 2.191, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "CHF" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 26.432, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "THB" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 0.71146, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "TRY" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 0.67126, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "GBP" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 29.730, + "Date": "2025-01-24" + }, + { + "SourceCurrency": { + "Code": "USD" + }, + "TargetCurrency": { + "Code": "CZK" + }, + "Value": 23.958, + "Date": "2025-01-24" + } +] \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.ExchangeRates.Domain.UnitTests/ExchangeRateProviderTests.cs b/jobs/Backend/Task/Mews.ExchangeRates.Domain.UnitTests/ExchangeRateProviderTests.cs new file mode 100644 index 000000000..701dc904a --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRates.Domain.UnitTests/ExchangeRateProviderTests.cs @@ -0,0 +1,105 @@ +using AutoFixture.Xunit2; +using Mews.ExchangeRates.Domain.Contracts; +using Mews.ExchangeRates.Domain.Exceptions; +using Mews.Reusable.UnitTests.Attributes; +using Mews.Reusable.UnitTests; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using System.ComponentModel.DataAnnotations; + +namespace Mews.ExchangeRates.Domain.UnitTests; + +public class ExchangeRateProviderTests +{ + + [Theory, InlineAutoNSubstituteData()] + public async Task GetExchangeRatesAsync_Throws_WhenReadFails( + string testReadExceptionMessage, + List currencies, + [Frozen] IFaultTolerantExchangeRateReader exchangeRateReader, + ExchangeRateProvider systemUnderTest + ) + { + //Prepare + exchangeRateReader.GetExchangeRatesAsync().Throws(new Exception(testReadExceptionMessage)); + + //Act + var GetExchangeRatesAction = async () => await systemUnderTest.GetExchangeRatesAsync(currencies); + + //Assert + var exception = await Assert.ThrowsAsync(GetExchangeRatesAction); + Assert.Equal(exception.InnerException!.Message, testReadExceptionMessage); + } + + [Theory, InlineAutoNSubstituteData()] + public async Task GetExchangeRatesAsync_ReturnsEmptyList_WhenRequestedCurrenciesNotFound( + List currencies, + List readExchangeRates, + [Frozen] IExchangeRateReader exchangeRateReader, + ExchangeRateProvider systemUnderTest) + { + //Prepare + exchangeRateReader.GetExchangeRatesAsync().Returns(readExchangeRates); + + //Act + var result = await systemUnderTest.GetExchangeRatesAsync(currencies); + + //Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Theory, InlineAutoNSubstituteData()] + public async Task GetExchangeRatesAsync_ReturnsOneElement_WhenARequestedCurrencyFound( + List currencies, + List readExchangeRates, + [Frozen] IFaultTolerantExchangeRateReader exchangeRateReader, + ExchangeRateProvider systemUnderTest) + { + //Prepare + var selectedCurrencyCode = readExchangeRates.First().SourceCurrency.Code; + currencies.Add(new Currency(selectedCurrencyCode)); + exchangeRateReader.GetExchangeRatesAsync().Returns(readExchangeRates); + + //Act + var result = await systemUnderTest.GetExchangeRatesAsync(currencies); + + //Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.Single(result); + var resultExchangeRate = result.Single(); + Assert.NotNull(resultExchangeRate); + Assert.Equal(selectedCurrencyCode, resultExchangeRate.SourceCurrency.Code); + Assert.NotEqual(selectedCurrencyCode, resultExchangeRate.TargetCurrency.Code); + } + + [Theory, InlineAutoNSubstituteData()] + public async Task GetExchangeRatesAsync_ReturnsCorrectElements_WhenRequestedCurrenciesFound( + List nonMatchingReadExchangeRates, + List matchingReadExchangeRates, + [Frozen] IFaultTolerantExchangeRateReader exchangeRateReader, + ExchangeRateProvider systemUnderTest) + { + //Prepare + var currencies = matchingReadExchangeRates.Select(x => new Currency(x.SourceCurrency.Code)); + var readExchangeRates = matchingReadExchangeRates.Concat(nonMatchingReadExchangeRates).Shuffle(); + exchangeRateReader.GetExchangeRatesAsync().Returns(readExchangeRates); + + //Act + var result = await systemUnderTest.GetExchangeRatesAsync(currencies); + + //Assert + Assert.NotNull(result); + Assert.NotEmpty(result); + Assert.Equal(matchingReadExchangeRates.Count, result.Count()); + + //For each element, there is only one with same currency Code and it is the same instance + Assert.True(matchingReadExchangeRates.All(er => result.Single(resultER => resultER.SourceCurrency.Code == er.SourceCurrency.Code) == er)); + + Func sourceCurrencyCode = er => er.SourceCurrency.Code; + Assert.True(result.OrderBy(sourceCurrencyCode).SequenceEqual(matchingReadExchangeRates.OrderBy(sourceCurrencyCode))); + } + + +} diff --git a/jobs/Backend/Task/Mews.ExchangeRates.Domain.UnitTests/FaultTolerantExchangeRateReaderTests.cs b/jobs/Backend/Task/Mews.ExchangeRates.Domain.UnitTests/FaultTolerantExchangeRateReaderTests.cs new file mode 100644 index 000000000..96e6c3dae --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRates.Domain.UnitTests/FaultTolerantExchangeRateReaderTests.cs @@ -0,0 +1,116 @@ +using AutoFixture.Xunit2; +using Mews.ExchangeRates.Domain.Contracts; +using Mews.Reusable.UnitTests.Attributes; +using NSubstitute; +using NSubstitute.ExceptionExtensions; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; +using System.Text; +using System.Threading.Tasks; + +namespace Mews.ExchangeRates.Domain.UnitTests; +public class FaultTolerantExchangeRateReaderTests() +{ + [Theory] + [InlineAutoNSubstituteData()] + public async Task GetExchangeRatesAsync_ReturnsSaved_WhenSavedForToday( + List savedRates, + [Frozen] IExchangeRateSaver exchangeRateSaver, + FaultTolerantExchangeRateReader systemUnderTest) + { + //Prepare + var today = DateOnly.FromDateTime(DateTime.UtcNow); + MockSaver(today, savedRates, exchangeRateSaver); + + //Act + var result = await systemUnderTest.GetExchangeRatesAsync(); + + //Assert + Assert.True(result.SequenceEqual(savedRates)); + } + [Theory] + [InlineAutoNSubstituteData()] + public async Task GetExchangeRatesAsync_ReturnsSaved_WhenExceptionReading( + List savedRates, + [Frozen] IExchangeRateReader innerReader, + [Frozen] IExchangeRateSaver exchangeRateSaver, + FaultTolerantExchangeRateReader systemUnderTest) + { + //Prepare + MockSaver(GetDateBeforeToday(), savedRates, exchangeRateSaver); + innerReader.GetExchangeRatesAsync().ThrowsAsync(new Exception("testing")); + + //Act + var result = await systemUnderTest.GetExchangeRatesAsync(); + + //Assert + Assert.True(result.SequenceEqual(savedRates)); + } + + [Theory] + [InlineAutoNSubstituteData()] + public async Task GetExchangeRatesAsync_ReadsNew_WhenSavedForBeforeToday( + List savedRates, + List readRates, + [Frozen] IExchangeRateReader innerReader, + [Frozen] IExchangeRateSaver exchangeRateSaver, + FaultTolerantExchangeRateReader systemUnderTest) + { + //Prepare + MockSaver(GetDateBeforeToday(), savedRates, exchangeRateSaver); + innerReader.GetExchangeRatesAsync().Returns(readRates); + + //Act + var result = await systemUnderTest.GetExchangeRatesAsync(); + + //Assert + Assert.True(result.SequenceEqual(readRates)); + Received.InOrder(async () => + { + await innerReader.GetExchangeRatesAsync(); + await exchangeRateSaver.SaveExchangeRatesAsync(readRates); + } + ); + } + + private static DateOnly GetDateBeforeToday() + { + return DateOnly.FromDateTime(DateTime.UtcNow.AddDays(-5)); + } + + private static void MockSaver(DateOnly date, List savedRates, IExchangeRateSaver exchangeRateSaver) + { + savedRates.ForEach(r => r.Date = date); + exchangeRateSaver.GetExchangeRatesAsync().Returns(savedRates); + } + + [Theory] + [InlineAutoNSubstituteData()] + public async Task GetExchangeRatesAsync_ReadsNew_WhenNotSaved( + List readRates, + [Frozen] IExchangeRateReader innerReader, + [Frozen] IExchangeRateSaver exchangeRateSaver, + FaultTolerantExchangeRateReader systemUnderTest) + { + //Prepare + var savedRates = new List(); + exchangeRateSaver.GetExchangeRatesAsync().Returns(savedRates); + innerReader.GetExchangeRatesAsync().Returns(readRates); + + //Act + var result = await systemUnderTest.GetExchangeRatesAsync(); + + //Assert + Assert.True(result.SequenceEqual(readRates)); + Received.InOrder(async () => + { + await innerReader.GetExchangeRatesAsync(); + await exchangeRateSaver.SaveExchangeRatesAsync(readRates); + } + ); + + } + +} diff --git a/jobs/Backend/Task/Mews.ExchangeRates.Domain.UnitTests/Mews.ExchangeRates.Domain.UnitTests.csproj b/jobs/Backend/Task/Mews.ExchangeRates.Domain.UnitTests/Mews.ExchangeRates.Domain.UnitTests.csproj new file mode 100644 index 000000000..b70e9e3be --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRates.Domain.UnitTests/Mews.ExchangeRates.Domain.UnitTests.csproj @@ -0,0 +1,28 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/Mews.ExchangeRates.Domain/Configuration/ExchangeRateOptions.cs b/jobs/Backend/Task/Mews.ExchangeRates.Domain/Configuration/ExchangeRateOptions.cs new file mode 100644 index 000000000..384b82163 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRates.Domain/Configuration/ExchangeRateOptions.cs @@ -0,0 +1,31 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Mews.ExchangeRates.Domain.Configuration; + +public class ExchangeRateOptions +{ + [Required] + [NotNull] + public string[]? Currencies { get; set; } + + /// + /// This configuration does not belong to domain, but infra. I decided to keep it in the same place for simplicity. + /// + [Required] + [NotNull] + public string? SourceUri { get; set; } + + /// + /// This configuration does not belong to domain, but infra + /// + + [Required] + [NotNull] + public string? DataFilePath { get; set; } +} diff --git a/jobs/Backend/Task/Mews.ExchangeRates.Domain/Contracts/IExchangeRateProvider.cs b/jobs/Backend/Task/Mews.ExchangeRates.Domain/Contracts/IExchangeRateProvider.cs new file mode 100644 index 000000000..c2d8a4430 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRates.Domain/Contracts/IExchangeRateProvider.cs @@ -0,0 +1,7 @@ +namespace Mews.ExchangeRates.Domain.Contracts +{ + public interface IExchangeRateProvider + { + Task> GetExchangeRatesAsync(IEnumerable currencies); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/Mews.ExchangeRates.Domain/Contracts/IExchangeRateReader.cs b/jobs/Backend/Task/Mews.ExchangeRates.Domain/Contracts/IExchangeRateReader.cs new file mode 100644 index 000000000..f53fe817d --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRates.Domain/Contracts/IExchangeRateReader.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Mews.ExchangeRates.Domain.Contracts +{ + public interface IExchangeRateReader + { + Task> GetExchangeRatesAsync(); + } +} diff --git a/jobs/Backend/Task/Mews.ExchangeRates.Domain/Contracts/IExchangeRateSaver.cs b/jobs/Backend/Task/Mews.ExchangeRates.Domain/Contracts/IExchangeRateSaver.cs new file mode 100644 index 000000000..446103b4e --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRates.Domain/Contracts/IExchangeRateSaver.cs @@ -0,0 +1,12 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Mews.ExchangeRates.Domain.Contracts; + +public interface IExchangeRateSaver: IExchangeRateReader +{ + Task SaveExchangeRatesAsync(IEnumerable exchangeRates); +} diff --git a/jobs/Backend/Task/Mews.ExchangeRates.Domain/Contracts/IFaultTolerantExchangeRateReader.cs b/jobs/Backend/Task/Mews.ExchangeRates.Domain/Contracts/IFaultTolerantExchangeRateReader.cs new file mode 100644 index 000000000..170df4c2a --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRates.Domain/Contracts/IFaultTolerantExchangeRateReader.cs @@ -0,0 +1,10 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Mews.ExchangeRates.Domain.Contracts +{ + public interface IFaultTolerantExchangeRateReader: IExchangeRateReader { } +} diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Mews.ExchangeRates.Domain/Currency.cs similarity index 53% rename from jobs/Backend/Task/Currency.cs rename to jobs/Backend/Task/Mews.ExchangeRates.Domain/Currency.cs index f375776f2..ff27ce2dc 100644 --- a/jobs/Backend/Task/Currency.cs +++ b/jobs/Backend/Task/Mews.ExchangeRates.Domain/Currency.cs @@ -1,16 +1,11 @@ -namespace ExchangeRateUpdater +namespace Mews.ExchangeRates.Domain { - public class Currency + public class Currency(string code) { - public Currency(string code) - { - Code = code; - } - /// /// Three-letter ISO 4217 code of the currency. /// - public string Code { get; } + public string Code { get; } = code; public override string ToString() { diff --git a/jobs/Backend/Task/Mews.ExchangeRates.Domain/Exceptions/DataReadException.cs b/jobs/Backend/Task/Mews.ExchangeRates.Domain/Exceptions/DataReadException.cs new file mode 100644 index 000000000..1f6668f72 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRates.Domain/Exceptions/DataReadException.cs @@ -0,0 +1,13 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Mews.ExchangeRates.Domain.Exceptions +{ + public class DataReadException(string message, Exception? innerException) : Exception(message, innerException) + { + public DataReadException(string message) : this(message, null) { } + } +} diff --git a/jobs/Backend/Task/Mews.ExchangeRates.Domain/ExchangeRate.cs b/jobs/Backend/Task/Mews.ExchangeRates.Domain/ExchangeRate.cs new file mode 100644 index 000000000..c14a0f0e0 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRates.Domain/ExchangeRate.cs @@ -0,0 +1,27 @@ +namespace Mews.ExchangeRates.Domain; + +public class ExchangeRate(Currency sourceCurrency, Currency targetCurrency, decimal value, DateOnly date) +{ + public Currency SourceCurrency { get; } = sourceCurrency; + + public Currency TargetCurrency { get; } = targetCurrency; + + public decimal Value { get; } = value; + + public DateOnly Date { get; set; } = date; + + public override string ToString() + { + return $"1 {SourceCurrency} = {Value} {TargetCurrency} on {Date:yyyy-MM-dd}."; + } + + public override bool Equals(object? obj) + { + return ToString() == obj?.ToString(); + } + + public override int GetHashCode() + { + return HashCode.Combine(ToString()); + } +} diff --git a/jobs/Backend/Task/Mews.ExchangeRates.Domain/ExchangeRateProvider.cs b/jobs/Backend/Task/Mews.ExchangeRates.Domain/ExchangeRateProvider.cs new file mode 100644 index 000000000..9a3de0c70 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRates.Domain/ExchangeRateProvider.cs @@ -0,0 +1,40 @@ +using Mews.ExchangeRates.Domain.Contracts; +using Mews.ExchangeRates.Domain.Exceptions; +using Microsoft.Extensions.Logging; +using System.Collections.Generic; +using System.Linq; + +namespace Mews.ExchangeRates.Domain +{ + public class ExchangeRateProvider( + IFaultTolerantExchangeRateReader exchangeRateReader, + ILogger logger) : IExchangeRateProvider + { + + /// + /// Should return exchange rates among the specified currencies that are defined by the source. But only those defined + /// by the source, do not return calculated exchange rates. E.g. if the source contains "CZK/USD" but not "USD/CZK", + /// do not return exchange rate "USD/CZK" with value calculated as 1 / "CZK/USD". If the source does not provide + /// some of the currencies, ignore them. + /// + public async Task> GetExchangeRatesAsync(IEnumerable currencies) + { + logger.LogInformation("Reading Exchange Rates from source"); + var readRates = await ReadRatesFromSource(exchangeRateReader); + return readRates.Where(r => currencies.Select(c => c.Code).Contains(r.SourceCurrency.Code)); + } + + private static async Task> ReadRatesFromSource(IExchangeRateReader exchangeRateReader) + { + try + { + var rates = await exchangeRateReader.GetExchangeRatesAsync(); + return rates; + } + catch (Exception ex) + { + throw new DataReadException(ex.Message, ex); + } + } + } +} diff --git a/jobs/Backend/Task/Mews.ExchangeRates.Domain/FaultTolerantExchangeRateReader.cs b/jobs/Backend/Task/Mews.ExchangeRates.Domain/FaultTolerantExchangeRateReader.cs new file mode 100644 index 000000000..b48bcfe1c --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRates.Domain/FaultTolerantExchangeRateReader.cs @@ -0,0 +1,58 @@ +using Mews.ExchangeRates.Domain.Contracts; +using Microsoft.Extensions.Logging; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Mews.ExchangeRates.Domain; +public class FaultTolerantExchangeRateReader( + IExchangeRateReader innerReader, + IExchangeRateSaver exchangeRateSaver, + ILogger logger) : IFaultTolerantExchangeRateReader +{ + public async Task> GetExchangeRatesAsync() + { + var today = DateOnly.FromDateTime(DateTime.UtcNow); + var cachedRates = await exchangeRateSaver.GetExchangeRatesAsync(); + if (cachedRates.Any()) + { + //We may need to check date for each rate individually + if (today.Equals(cachedRates.Max(r => r.Date))) + { + logger.LogInformation("Returning cached rates."); + return cachedRates; + } + } + IEnumerable readRates = await ReadNewRates(); + return readRates; + } + + private async Task> ReadNewRates() + { + try + { + var read = await innerReader.GetExchangeRatesAsync(); + await SaveRates(exchangeRateSaver, read); + return read; + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error occurred while reading Rates. Returning cached rates."); + return await exchangeRateSaver.GetExchangeRatesAsync(); + } + } + + private async Task SaveRates(IExchangeRateSaver exchangeRateSaver, IEnumerable read) + { + try + { + await exchangeRateSaver.SaveExchangeRatesAsync(read); + } + catch (Exception ex) + { + logger.LogWarning(ex, "Error occurred while saving Rates."); + } + } +} diff --git a/jobs/Backend/Task/Mews.ExchangeRates.Domain/Mews.ExchangeRates.Domain.csproj b/jobs/Backend/Task/Mews.ExchangeRates.Domain/Mews.ExchangeRates.Domain.csproj new file mode 100644 index 000000000..a7e7a34f3 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRates.Domain/Mews.ExchangeRates.Domain.csproj @@ -0,0 +1,14 @@ + + + + net8.0 + enable + enable + + + + + + + + diff --git a/jobs/Backend/Task/Mews.ExchangeRates.Domain/ServiceCollectionExtensions.cs b/jobs/Backend/Task/Mews.ExchangeRates.Domain/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..be1a27649 --- /dev/null +++ b/jobs/Backend/Task/Mews.ExchangeRates.Domain/ServiceCollectionExtensions.cs @@ -0,0 +1,15 @@ +using Mews.ExchangeRates.Domain.Contracts; +using Microsoft.Extensions.DependencyInjection; + +namespace Mews.ExchangeRates.Domain +{ + public static class ServiceCollectionExtensions + { + public static IServiceCollection AddExchangeRatesDomain( this IServiceCollection services) + { + services.AddSingleton(); + services.AddSingleton(); + return services; + } + } +} diff --git a/jobs/Backend/Task/Mews.Reusable.UnitTests/Attributes/InlineAutoNSubstituteDataAttribute.cs b/jobs/Backend/Task/Mews.Reusable.UnitTests/Attributes/InlineAutoNSubstituteDataAttribute.cs new file mode 100644 index 000000000..ec1382c9a --- /dev/null +++ b/jobs/Backend/Task/Mews.Reusable.UnitTests/Attributes/InlineAutoNSubstituteDataAttribute.cs @@ -0,0 +1,47 @@ +using AutoFixture; +using AutoFixture.AutoNSubstitute; +using AutoFixture.Xunit2; +using Mews.Reusable.UnitTests.Customizations; +using NSubstitute.Routing.Handlers; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; +using System.Windows.Markup; + +namespace Mews.Reusable.UnitTests.Attributes +{ + [AttributeUsage(AttributeTargets.Method,AllowMultiple = true)] + public class InlineAutoNSubstituteDataAttribute(params object[] values) : AutoNSubstituteDataAttribute + { + private readonly object[] _values = values; + + public override IEnumerable GetData(MethodInfo testMethod) + { + var autoDataWithSubstitutes = base.GetData(testMethod); + + var arrayOfValues = autoDataWithSubstitutes.First(); + + for (var i = 0; i < _values.Length; i++) + { + //types could be different + if (arrayOfValues[i].GetType().Equals(_values[i].GetType())) + arrayOfValues[i] = _values[i]; + else throw new Exception($"Incorrect type for value of parameter {i}"); + } + + return autoDataWithSubstitutes; + } + } + + public class AutoNSubstituteDataAttribute() : AutoDataAttribute(() => + { + var myCustomFixture = new Fixture().Customize(new AutoNSubstituteCustomization()); + //More customizations can be added here + myCustomFixture.Customize(new DateOnlyCustomization()); + return myCustomFixture; + } + ); +} diff --git a/jobs/Backend/Task/Mews.Reusable.UnitTests/Autofixture/DateOnlyCustomization.cs b/jobs/Backend/Task/Mews.Reusable.UnitTests/Autofixture/DateOnlyCustomization.cs new file mode 100644 index 000000000..7bce7b81f --- /dev/null +++ b/jobs/Backend/Task/Mews.Reusable.UnitTests/Autofixture/DateOnlyCustomization.cs @@ -0,0 +1,18 @@ +using AutoFixture; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Mews.Reusable.UnitTests.Customizations +{ + public class DateOnlyCustomization : ICustomization + { + public void Customize(IFixture fixture) + { + fixture.Customize(composer => + composer.FromFactory((dateTime) => DateOnly.FromDateTime(dateTime))); + } + } +} diff --git a/jobs/Backend/Task/Mews.Reusable.UnitTests/EmbeddedResourceFileReader.cs b/jobs/Backend/Task/Mews.Reusable.UnitTests/EmbeddedResourceFileReader.cs new file mode 100644 index 000000000..3ef8f8583 --- /dev/null +++ b/jobs/Backend/Task/Mews.Reusable.UnitTests/EmbeddedResourceFileReader.cs @@ -0,0 +1,26 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection.PortableExecutable; +using System.Reflection; +using System.Text; +using System.Threading.Tasks; + +namespace Mews.Reusable.UnitTests +{ + public static class EmbeddedResourceFileReader + { + public static string ReadFileContent(Assembly assembly, string fileName) + { + string foundResourceName = assembly.GetManifestResourceNames().Single(str => str.EndsWith(fileName)); + using Stream? stream = assembly.GetManifestResourceStream(foundResourceName); + if (stream is null) + { + throw new FileNotFoundException("Resource file Not Found!", fileName); + } + using var reader = new StreamReader(stream!); + return reader.ReadToEnd(); + } + } +} diff --git a/jobs/Backend/Task/Mews.Reusable.UnitTests/EnumerableExtensions.cs b/jobs/Backend/Task/Mews.Reusable.UnitTests/EnumerableExtensions.cs new file mode 100644 index 000000000..aa94ade5a --- /dev/null +++ b/jobs/Backend/Task/Mews.Reusable.UnitTests/EnumerableExtensions.cs @@ -0,0 +1,22 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; + +namespace Mews.Reusable.UnitTests +{ + public static class EnumerableExtensions + { + public static IEnumerable Shuffle(this IEnumerable list) + { + var r = new Random(); + var shuffledList = + list. + Select(x => new { Number = r.Next(), Item = x }). + OrderBy(x => x.Number). + Select(x => x.Item); + return shuffledList.ToList(); + } + } +} diff --git a/jobs/Backend/Task/Mews.Reusable.UnitTests/Mews.Reusable.UnitTests.csproj b/jobs/Backend/Task/Mews.Reusable.UnitTests/Mews.Reusable.UnitTests.csproj new file mode 100644 index 000000000..dcbece1f4 --- /dev/null +++ b/jobs/Backend/Task/Mews.Reusable.UnitTests/Mews.Reusable.UnitTests.csproj @@ -0,0 +1,27 @@ + + + + net8.0 + enable + enable + + false + true + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs deleted file mode 100644 index 379a69b1f..000000000 --- a/jobs/Backend/Task/Program.cs +++ /dev/null @@ -1,43 +0,0 @@ -using System; -using System.Collections.Generic; -using System.Linq; - -namespace ExchangeRateUpdater -{ - public static class Program - { - private static IEnumerable currencies = new[] - { - new Currency("USD"), - new Currency("EUR"), - new Currency("CZK"), - new Currency("JPY"), - new Currency("KES"), - new Currency("RUB"), - new Currency("THB"), - new Currency("TRY"), - new Currency("XYZ") - }; - - public static void Main(string[] args) - { - try - { - var provider = new ExchangeRateProvider(); - var rates = provider.GetExchangeRates(currencies); - - Console.WriteLine($"Successfully retrieved {rates.Count()} exchange rates:"); - foreach (var rate in rates) - { - Console.WriteLine(rate.ToString()); - } - } - catch (Exception e) - { - Console.WriteLine($"Could not retrieve exchange rates: '{e.Message}'."); - } - - Console.ReadLine(); - } - } -} diff --git a/jobs/Backend/Task/SampleDataSource/20250122/daily.txt b/jobs/Backend/Task/SampleDataSource/20250122/daily.txt new file mode 100644 index 000000000..f182aabf0 --- /dev/null +++ b/jobs/Backend/Task/SampleDataSource/20250122/daily.txt @@ -0,0 +1,33 @@ +22 Jan 2025 #15 +Country|Currency|Amount|Code|Rate +Australia|dollar|1|AUD|15.144 +Brazil|real|1|BRL|4.008 +Bulgaria|lev|1|BGN|12.856 +Canada|dollar|1|CAD|16.793 +China|renminbi|1|CNY|3.313 +Denmark|krone|1|DKK|3.370 +EMU|euro|1|EUR|25.145 +Hongkong|dollar|1|HKD|3.092 +Hungary|forint|100|HUF|6.118 +Iceland|krona|100|ISK|17.164 +IMF|SDR|1|XDR|31.326 +India|rupee|100|INR|27.872 +Indonesia|rupiah|1000|IDR|1.478 +Israel|new shekel|1|ILS|6.805 +Japan|yen|100|JPY|15.448 +Malaysia|ringgit|1|MYR|5.427 +Mexico|peso|1|MXN|1.171 +New Zealand|dollar|1|NZD|13.675 +Norway|krone|1|NOK|2.139 +Philippines|peso|100|PHP|41.230 +Poland|zloty|1|PLN|5.945 +Romania|leu|1|RON|5.053 +Singapore|dollar|1|SGD|17.801 +South Africa|rand|1|ZAR|1.303 +South Korea|won|100|KRW|1.680 +Sweden|krona|1|SEK|2.192 +Switzerland|franc|1|CHF|26.612 +Thailand|baht|100|THB|71.142 +Turkey|lira|100|TRY|67.526 +United Kingdom|pound|1|GBP|29.771 +USA|dollar|1|USD|24.075 diff --git a/jobs/Backend/Task/SampleDataSource/20250124/daily.txt b/jobs/Backend/Task/SampleDataSource/20250124/daily.txt new file mode 100644 index 000000000..179f56bdf --- /dev/null +++ b/jobs/Backend/Task/SampleDataSource/20250124/daily.txt @@ -0,0 +1,33 @@ +24 Jan 2025 #17 +Country|Currency|Amount|Code|Rate +Australia|dollar|1|AUD|15.118 +Brazil|real|1|BRL|4.062 +Bulgaria|lev|1|BGN|12.829 +Canada|dollar|1|CAD|16.717 +China|renminbi|1|CNY|3.306 +Denmark|krone|1|DKK|3.363 +EMU|euro|1|EUR|25.095 +Hongkong|dollar|1|HKD|3.076 +Hungary|forint|100|HUF|6.137 +Iceland|krona|100|ISK|17.153 +IMF|SDR|1|XDR|31.223 +India|rupee|100|INR|27.771 +Indonesia|rupiah|1000|IDR|1.481 +Israel|new shekel|1|ILS|6.675 +Japan|yen|100|JPY|15.313 +Malaysia|ringgit|1|MYR|5.473 +Mexico|peso|1|MXN|1.186 +New Zealand|dollar|1|NZD|13.667 +Norway|krone|1|NOK|2.136 +Philippines|peso|100|PHP|41.033 +Poland|zloty|1|PLN|5.956 +Romania|leu|1|RON|5.043 +Singapore|dollar|1|SGD|17.759 +South Africa|rand|1|ZAR|1.304 +South Korea|won|100|KRW|1.671 +Sweden|krona|1|SEK|2.191 +Switzerland|franc|1|CHF|26.432 +Thailand|baht|100|THB|71.146 +Turkey|lira|100|TRY|67.126 +United Kingdom|pound|1|GBP|29.730 +USA|dollar|1|USD|23.958 diff --git a/jobs/Backend/Task/SampleDataSource/SourceUrl.txt b/jobs/Backend/Task/SampleDataSource/SourceUrl.txt new file mode 100644 index 000000000..05ff46c57 --- /dev/null +++ b/jobs/Backend/Task/SampleDataSource/SourceUrl.txt @@ -0,0 +1,7 @@ +Source 1 +https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt?date=21.01.2025 +https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt +https://www.cnb.cz/en/financial-markets/foreign-exchange-market/central-bank-exchange-rate-fixing/central-bank-exchange-rate-fixing/daily.txt;jsessionid=D7132243477A23866AA52530B72C959B?date=22.01.2025 + +Source 2 - Foreign exchange market rates +https://www.cnb.cz/cs/financni-trhy/devizovy-trh/kurzy-devizoveho-trhu/kurzy-devizoveho-trhu/denni_kurz.txt?date=22.01.2025 \ No newline at end of file