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