diff --git a/jobs/Backend/Task/Currency.cs b/jobs/Backend/Task/Currency.cs deleted file mode 100644 index f375776f25..0000000000 --- a/jobs/Backend/Task/Currency.cs +++ /dev/null @@ -1,20 +0,0 @@ -namespace ExchangeRateUpdater -{ - public class Currency - { - public Currency(string code) - { - Code = code; - } - - /// - /// Three-letter ISO 4217 code of the currency. - /// - public string Code { get; } - - public override string ToString() - { - return Code; - } - } -} diff --git a/jobs/Backend/Task/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRate.cs deleted file mode 100644 index 58c5bb10e0..0000000000 --- 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/ExchangeRatePovider/.dockerignore b/jobs/Backend/Task/ExchangeRatePovider/.dockerignore new file mode 100644 index 0000000000..fe1152bdb8 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/.dockerignore @@ -0,0 +1,30 @@ +**/.classpath +**/.dockerignore +**/.env +**/.git +**/.gitignore +**/.project +**/.settings +**/.toolstarget +**/.vs +**/.vscode +**/*.*proj.user +**/*.dbmdl +**/*.jfm +**/azds.yaml +**/bin +**/charts +**/docker-compose* +**/Dockerfile* +**/node_modules +**/npm-debug.log +**/obj +**/secrets.dev.yaml +**/values.dev.yaml +LICENSE +README.md +!**/.gitignore +!.git/HEAD +!.git/config +!.git/packed-refs +!.git/refs/heads/** \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRatePovider/.gitignore b/jobs/Backend/Task/ExchangeRatePovider/.gitignore new file mode 100644 index 0000000000..dfcfd56f44 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/.gitignore @@ -0,0 +1,350 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.rsuser +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Mono auto generated files +mono_crash.* + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +[Rr]eleases/ +x64/ +x86/ +[Aa][Rr][Mm]/ +[Aa][Rr][Mm]64/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ +[Ll]ogs/ + +# Visual Studio 2015/2017 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# Visual Studio 2017 auto generated files +Generated\ Files/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUnit +*.VisualState.xml +TestResult.xml +nunit-*.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# Benchmark Results +BenchmarkDotNet.Artifacts/ + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ + +# StyleCop +StyleCopReport.xml + +# Files built by Visual Studio +*_i.c +*_p.c +*_h.h +*.ilk +*.meta +*.obj +*.iobj +*.pch +*.pdb +*.ipdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*_wpftmp.csproj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# Visual Studio Trace Files +*.e2e + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# AxoCover is a Code Coverage Tool +.axoCover/* +!.axoCover/settings.json + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# Note: Comment the next line if you want to checkin your web deploy settings, +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# NuGet Symbol Packages +*.snupkg +# The packages folder can be ignored because of Package Restore +**/[Pp]ackages/* +# except build/, which is used as an MSBuild target. +!**/[Pp]ackages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/[Pp]ackages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt +*.appx +*.appxbundle +*.appxupload + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!?*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Including strong name files can present a security risk +# (https://github.com/github/gitignore/pull/2483#issue-259490424) +#*.snk + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm +ServiceFabricBackup/ +*.rptproj.bak + +# SQL Server files +*.mdf +*.ldf +*.ndf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings +*.rptproj.rsuser +*- [Bb]ackup.rdl +*- [Bb]ackup ([0-9]).rdl +*- [Bb]ackup ([0-9][0-9]).rdl + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# CodeRush personal settings +.cr/personal + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Tabs Studio +*.tss + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# OpenCover UI analysis results +OpenCover/ + +# Azure Stream Analytics local run output +ASALocalRun/ + +# MSBuild Binary and Structured Log +*.binlog + +# NVidia Nsight GPU debugger configuration file +*.nvuser + +# MFractors (Xamarin productivity tool) working folder +.mfractor/ + +# Local History for Visual Studio +.localhistory/ + +# BeatPulse healthcheck temp database +healthchecksdb + +# Backup folder for Package Reference Convert tool in Visual Studio 2017 +MigrationBackup/ + +# Ionide (cross platform F# VS Code tools) working folder +.ionide/ diff --git a/jobs/Backend/Task/ExchangeRatePovider/Directory.Build.props b/jobs/Backend/Task/ExchangeRatePovider/Directory.Build.props new file mode 100644 index 0000000000..4cb60ff179 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/Directory.Build.props @@ -0,0 +1,7 @@ + + + net10.0 + enable + enable + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRatePovider/ExchangeRateProvider.slnx b/jobs/Backend/Task/ExchangeRatePovider/ExchangeRateProvider.slnx new file mode 100644 index 0000000000..63c6a18771 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/ExchangeRateProvider.slnx @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRatePovider/README.md b/jobs/Backend/Task/ExchangeRatePovider/README.md new file mode 100644 index 0000000000..d6e9fcb699 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/README.md @@ -0,0 +1,366 @@ +# Exchange Rate Provider - Czech National Bank (CNB) + +## Introduction + +This project implements a production-ready Exchange Rate Provider for the Czech National Bank (CNB), designed as a programming exercise with real-world applications. The solution provides exchange rate data by consuming the official CNB API and delivers it through both a REST API and a console application. + +### Project Overview + +The Exchange Rate Provider is built following **Clean Architecture** principles, ensuring separation of concerns, testability, and maintainability. The solution is organized into the following projects: + +#### Source Projects (`src/`) + +- **ExchangeRateProvider.Domain**: Core domain entities and business rules (Currency, ExchangeRate) +- **ExchangeRateProvider.Application**: Application services, interfaces, and use cases +- **ExchangeRateProvider.Infrastructure**: External integrations including CNB API client, caching, and resilience policies +- **ExchangeRateProvider.Api**: REST API with endpoints, authentication, rate limiting, and middleware +- **ExchangeRateProvider.ConsoleApp**: Console application to demonstrate functionality via command line interface + +#### Test Projects (`test/`) + +- **ExchangeRateProvider.Domain.Tests**: Unit tests for domain entities +- **ExchangeRateProvider.Application.UnitTests**: Unit tests for application services +- **ExchangeRateProvider.Infrastructure.Tests**: Tests for infrastructure components and external integrations +- **ExchangeRateProvider.Api.Tests**: Integration tests for API endpoints + +The application provides exchange rate data through the following interfaces: + +- **REST API**: Exposes an exchange rate endpoint for external consumers +- **Console Application**: Command-line interface for direct exchange rate queries + +### Data Source + +The application consumes exchange rate data from the Czech National Bank's official API: + +- **API Documentation**: https://api.cnb.cz/cnbapi/swagger-ui.html#/%2Fexrates/dailyUsingGET_1 +- **Data Provider**: Czech National Bank +- **Update Frequency**: Exchange rates are updated once daily on weekdays after 2:30 PM CET. No updates occur on weekends or public holidays. + +The implementation is built with production-grade considerations including error handling, caching, rate limiting, authentication, and comprehensive testing coverage. + +## Architecture & Design Decisions + +### Minimal APIs Approach + +We chose to implement **ASP.NET Core Minimal APIs** instead of traditional controllers. This decision provides several benefits: + +- **Modern .NET Approach**: Explores the latest API development patterns introduced in .NET 6+ +- **Reduced Boilerplate**: Less ceremony and cleaner endpoint definitions +- **Performance**: Slightly better performance due to reduced overhead +- **Simplicity**: More direct mapping between HTTP endpoints and business logic + +```csharp +// Example from ExchangeRateEndpoints.cs +app.MapGet("/exchange-rates", async ( + [FromQuery] string currencies, + IExchangeRateProviderService service, + CancellationToken cancellationToken) => +{ + // Direct endpoint implementation +}); +``` + +### Interactive API Documentation with Scalar + +As this project serves as a learning exercise, for API documentation, we implemented **Scalar** instead of traditional Swagger UI or NSwag. This choice was made to: + +- **Explore Modern Tooling**: Scalar provides a more modern, interactive documentation experience +- **Enhanced Developer Experience**: Better UI/UX compared to traditional Swagger implementations +- **OpenAPI Integration**: Seamless integration with .NET's built-in OpenAPI support + +```csharp +// API documentation setup in Program.cs +if (app.Environment.IsDevelopment()) +{ + app.MapOpenApi(); // Built-in .NET OpenAPI support + app.MapScalarApiReference(); // Scalar interactive documentation +} +``` + +This combination provides developers with an excellent interactive experience for exploring and testing the API endpoints directly from the browser. + +#### API Documentation Access Points + +When running the API in development mode, you can access: + +- **OpenAPI Definition**: `/openapi/v1.json` - Raw OpenAPI specification in JSON format +- **Scalar Interactive Documentation**: `/scalar/` - Modern, interactive API documentation interface +- **HTTP File Testing**: `src/ExchangeRateProvider.Api/ExchangeRateProvider.http` - Pre-configured HTTP requests for testing, making it easy to test the API functionality directly from your IDE. + +### Intelligent Caching Strategy + +One of the key architectural decisions was implementing an intelligent **in-memory caching** layer to avoid excessive calls to the CNB API. The CNB website explicitly states: + +> *"Unjustified and excessive access to this site will be considered undesirable and will be restricted as part of the security measures applied."* + +To respect this requirement and optimize performance, we implemented an in-memory caching mechanism with the following components: + +#### Cache Implementation Components + +**1. CnbCacheDateTimeProvider** +- Calculates optimal cache expiration times based on CNB's fixing schedule +- Handles Prague timezone conversions and daylight saving time automatically +- Accounts for business days vs. weekends/holidays +- Provides configurable buffer time (default: 5 minutes) after the 2:30 PM fixing time + +**2. CnbCachedExchangeRateProviderDecorator** +- Implements the Decorator pattern to add caching +- Uses multiple cache keys for efficient data retrieval and management +- Prevents concurrent API calls using `GetOrCreateAsync` pattern +- Automatically invalidates cache based on CNB's schedule + +#### Cache Expiration Logic + +The cache intelligently calculates when to expire based on CNB's publishing schedule: + +- **Business Days (Mon-Fri)**: + - Before 2:30 PM Prague time → Cache expires at 2:35 PM (2:30 PM + 5min buffer) + - After 2:30 PM Prague time → Cache expires next business day at 2:35 PM +- **Weekends**: Cache expires on next Monday at 2:35 PM Prague time +- **Public Holidays**: Simplified approach - treats as regular weekends (next business day expiration) + +#### Holiday Handling Decision + +We deliberately chose **not** to integrate with external holiday services for Czech public holidays. This decision was made because: + +1. **Minimal Impact**: A single API call on a holiday has negligible performance impact +2. **Simplicity**: Avoids external dependencies and potential service failures +3. **Reliability**: Reduces points of failure in the system +4. **Cost-Benefit**: The complexity of holiday service integration outweighs the marginal benefit + +The system treats holidays as regular weekend days, ensuring cache expiration on the next business day when CNB resumes publishing rates. + +#### Cache Key Strategy + +``` +ExchangeRates:LatestValidFor → "2024-01-15" +ExchangeRates:ValidFor:2024-01-15 → [Exchange Rate List] +ExchangeRates:LatestValid:Loader → Loader lock for concurrency +``` + +This multi-key approach enables: +- Fast lookups for the latest valid date +- Efficient storage of historical exchange rates +- Prevention of concurrent API calls through loader locking +- Automatic cleanup through time-based expiration + +### Design Patterns Implementation + +The solution leverages several well-established design patterns to ensure maintainability, extensibility, and separation of concerns: + +#### Decorator Pattern + +The **Decorator Pattern** is implemented through `CnbCachedExchangeRateProviderDecorator` to add caching functionality without modifying the original exchange rate provider: + +```csharp +// Decorator wraps any IExchangeRateProvider implementation +internal class CnbCachedExchangeRateProviderDecorator( + IExchangeRateProvider inner, + IMemoryCache cache, + ICacheDateTimeProvider dateTimeProvider, + ILogger logger) + : IExchangeRateProvider +{ + public async Task> GetLatestAsync(CancellationToken cancellationToken = default) + { + // Cache logic wraps the inner provider call + return await cache.GetOrCreateAsync(LoaderKey, async entry => + { + return await inner.GetLatestAsync(cancellationToken); + }); + } +} +``` + +**Benefits:** +- **Single Responsibility**: Caching logic is separate from data fetching logic +- **Open/Closed Principle**: Can add caching to any provider without modification +- **Composability**: Can stack multiple decorators (e.g., retry + cache + logging) + +#### Strategy Pattern + +The **Strategy Pattern** is implemented through the `IExchangeRateProvider` interface with `CnbExchangeRateProvider` as a concrete strategy: + +```csharp +// Strategy interface +public interface IExchangeRateProvider +{ + Task> GetLatestAsync(CancellationToken cancellationToken = default); +} + +// Concrete strategy for CNB +internal class CnbExchangeRateProvider : IExchangeRateProvider +{ + // CNB-specific implementation +} +``` + +**Benefits:** +- **Flexibility**: Easy to switch between different exchange rate providers +- **Extensibility**: New providers (ECB, Federal Reserve, etc.) can be added without changing existing code +- **Testability**: Strategies can be easily mocked and tested independently + +#### Options Pattern + +The **Options Pattern** is extensively used throughout the application for strongly-typed configuration management, following .NET best practices: + +```csharp +// Configuration class with validation attributes +public record CnbApiOptions +{ + public const string SectionName = "CnbApi"; + + public required string HttpClientName { get; init; } + public required string BaseUrl { get; init; } + public required string DailyExchangeRatesEndpoint { get; init; } +} + +// Registration in ServiceCollectionExtensions +services.Configure(configuration.GetSection(CnbApiOptions.SectionName)); + +// Usage with dependency injection +public class CnbExchangeRateProvider(IOptions options, HttpClient httpClient) +{ + private readonly CnbApiOptions _options = options.Value; + + public async Task> GetLatestAsync(CancellationToken cancellationToken = default) + { + var endpoint = _options.BaseUrl + _options.DailyExchangeRatesEndpoint; + // Use configured values... + } +} +``` + +**Configuration Structure:** +```json +{ + "Auth0": { + "Authority": "https://dev-ysubdf4h0li5mmwc.us.auth0.com/", + "Audience": "exchange-rate-api" + }, + "CnbApi": { + "HttpClientName": "CnbApiHttpClient", + "BaseUrl": "https://api.cnb.cz", + "DailyExchangeRatesEndpoint": "/cnbapi/exrates/daily" + } +} +``` + +**Benefits:** +- **Type Safety**: Eliminates magic strings and provides compile-time validation +- **Validation**: Built-in support for configuration validation using data annotations +- **Testability**: Easy to mock and test with different configuration values +- **Hot Reload**: Supports configuration changes without application restart (with `IOptionsMonitor`) +- **Environment-Specific**: Different configurations per environment (Development, Production) + +#### Future Factory Pattern Consideration + +While not currently implemented, the **Factory Pattern** is planned for future releases to manage multiple exchange rate provider strategies: + +```csharp +// Future implementation concept +public interface IExchangeRateProviderFactory +{ + IExchangeRateProvider CreateProvider(ExchangeRateProviderType type); +} + +public enum ExchangeRateProviderType +{ + CzechNationalBank, + EuropeanCentralBank, + FederalReserve, + // Additional providers... +} +``` + +**Future Benefits:** +- **Centralized Creation**: Single point for provider instantiation and configuration +- **Dynamic Selection**: Runtime selection of providers based on configuration or requirements +- **Dependency Management**: Proper injection of provider-specific dependencies + +This pattern will become valuable when supporting multiple central banks or financial data providers, allowing the application to seamlessly switch between different data sources based on configuration or business requirements. + +## Technology Stack & Deployment + +### Technology Stack + +- **.NET 10**: Latest Long Term Support (LTS) version providing enhanced performance, security, and long-term support +- **ASP.NET Core**: For REST API implementation with built-in dependency injection, middleware pipeline, and health checks +- **Docker**: Containerization for consistent deployment across environments +- **Auth0**: Authentication and authorization for API security +- **Memory Caching**: In-memory caching for improved performance and reduced API calls +- **Resilience Patterns**: Circuit breaker, retry policies with exponential backoff, and timeout handling for external API integration + +### Authentication + +The API is secured using **Auth0** with JWT tokens and client credentials flow. An Auth0 application has been configured to generate JWT tokens for API access. + +#### Obtaining an Access Token + +To call the API endpoints, you need to obtain a valid JWT token from Auth0: + +```bash +curl --request POST \ + --url https://dev-ysubdf4h0li5mmwc.us.auth0.com/oauth/token \ + --header 'content-type: application/json' \ + --data '{ + "client_id":"JjZobrdF99vYG7iV34UPNB5OxKbhzSPA", + "client_secret":"NsfcnUI-IWpufHp9CvCRXDOW5sN924lC7X-XhZaudhh99mGwyf1GnIVSRKyvk8pQ", + "audience":"exchange-rate-api", + "grant_type":"client_credentials" + }' +``` + +This will return a JSON response containing the access token: + +```json +{ + "access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjZRTG94QUQ0MUM5bGlMVnBlbHpSbSJ9.eyJpc3MiOiJodHRwczovL2Rldi15c3ViZGY0aDBsaTVtbXdjLnVzLmF1dGgwLmNvbS8iLCJzdWIiOiJKalpvYnJkRjk5dllHN2lWMzRVUE5CNU94S2JoelNQQUBjbGllbnRzIiwiYXVkIjoiZXhjaGFuZ2UtcmF0ZS1hcGkiLCJpYXQiOjE3NjYzMTc2NzcsImV4cCI6MTc2NjQwNDA3NywiZ3R5IjoiY2xpZW50LWNyZWRlbnRpYWxzIiwiYXpwIjoiSmpab2JyZEY5OXZZRzdpVjM0VVBOQjVPeEtiaHpTUEEifQ.eblxRmlkuoZ_2LaS5gueKy6XBSYB5fX6LWiIevRG_CtlHIKkn-4aFQKbIxV2IH4p3uKnfB2uercybjb85RkxBy7HMLeP0kfEl-XDbqf4T1eNOhOe4kyck0a6oRKyZsBDuIWZJbYLBiD7s505Ysz1Gaik2ZPfBAr0ZtejlM4DRMrbWRLWYFJ2ElcHjG4dCkBbwcI8nLO0iKwAIyTqUch9YcAQrUJsEPs0d9EQROlpXpDsaXnENHAXt-wtXV-AXju55WZlkmes3selWICeKKPV-AZBYvV8a8dEPU5gHjIOx3e8jptiyA2QI4dJLRnHgU4FL-HgbBzIh97SSD3k7KUPWA", + "token_type": "Bearer" +} +``` + +Use the `access_token` value in your API requests as shown in the examples below. + +### Docker Deployment + +Both applications are containerized using Docker for consistent deployment across different environments. + +#### Console Application + +Build and run the console application: + +```bash +# Build the Docker image +docker build -f src/ExchangeRateProvider.ConsoleApp/Dockerfile -t exchange-rate-provider-console-app:latest . + +# Run the container +docker run --rm exchange-rate-provider-console-app:latest +``` + +#### API Application + +Build and run the API application: + +```bash +# Build the Docker image +docker build -f src/ExchangeRateProvider.Api/Dockerfile -t exchange-rate-provider-api:latest . + +# Run the container with port mapping +docker run --rm -p 8080:8080 -p 8081:8081 -e ASPNETCORE_ENVIRONMENT=Development exchange-rate-provider-api:latest +``` + +#### Testing the API + +Once the API is running, you can test it using curl with a valid access token: + +```bash +curl -X GET "http://localhost:8080/exchange-rates?currencies=USD,EUR,GBP,XYZ" \ + -H "Accept: application/json" \ + -H "Authorization: Bearer your_actual_access_token_here" +``` + +The API also includes a health check endpoint (no authentication required): + +```bash +curl -X GET "http://localhost:8080/health" \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Authentication/Auth0Authentication.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Authentication/Auth0Authentication.cs new file mode 100644 index 0000000000..18a99dfb47 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Authentication/Auth0Authentication.cs @@ -0,0 +1,44 @@ +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.IdentityModel.Tokens; + +namespace ExchangeRateProvider.Api.Authentication; + +/// +/// Provides extension methods for configuring Auth0 JWT authentication. +/// +public static class Auth0Authentication +{ + /// + /// Adds Auth0 JWT Bearer authentication to the service collection with token validation parameters. + /// + /// The service collection to add authentication services to. + /// The configuration containing Auth0 settings (Authority and Audience). + /// The service collection for method chaining. + public static IServiceCollection AddAuth0Authentication(this IServiceCollection services, + IConfiguration configuration) + { + services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + var authority = configuration["Auth0:Authority"]!; + var audience = configuration["Auth0:Audience"]!; + + options.Authority = authority; + options.Audience = audience; + + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuer = true, + ValidIssuer = authority.TrimEnd('/'), + ValidateAudience = true, + ValidAudience = audience, + ValidateLifetime = true, + ValidateIssuerSigningKey = true, + + NameClaimType = "sub" + }; + }); + + return services; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Constants/RateLimitPolicies.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Constants/RateLimitPolicies.cs new file mode 100644 index 0000000000..9260b2e45c --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Constants/RateLimitPolicies.cs @@ -0,0 +1,12 @@ +namespace ExchangeRateProvider.Api.Constants; + +/// +/// Contains constant values for rate limiting policy names used throughout the API. +/// +public static class RateLimitPolicies +{ + /// + /// Rate limiting policy name for exchange rates endpoints. + /// + public const string ExchangeRates = "exchange-rates"; +} diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Dockerfile b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Dockerfile new file mode 100644 index 0000000000..273fa82ba2 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Dockerfile @@ -0,0 +1,36 @@ +FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS base +USER $APP_UID +WORKDIR /app +EXPOSE 8080 +EXPOSE 8081 + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src + +# Copy Directory.Build.props first (contains TargetFramework) +COPY ["Directory.Build.props", "./"] + +# Copy all project files for dependency resolution +COPY ["src/ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj", "src/ExchangeRateProvider.Api/"] +COPY ["src/ExchangeRateProvider.Application/ExchangeRateProvider.Application.csproj", "src/ExchangeRateProvider.Application/"] +COPY ["src/ExchangeRateProvider.Domain/ExchangeRateProvider.Domain.csproj", "src/ExchangeRateProvider.Domain/"] +COPY ["src/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj", "src/ExchangeRateProvider.Infrastructure/"] + +# Restore dependencies +RUN dotnet restore "src/ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj" + +# Copy all source code +COPY . . + +# Build the application +RUN dotnet build "src/ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "src/ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "ExchangeRateProvider.Api.dll"] \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Dto/ExchangeRateDto.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Dto/ExchangeRateDto.cs new file mode 100644 index 0000000000..36efd54363 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Dto/ExchangeRateDto.cs @@ -0,0 +1,10 @@ +namespace ExchangeRateProvider.Api.Dto; + +/// +/// Data Transfer Object representing an exchange rate for API responses. +/// +/// The ISO 4217 code of the source currency from which to convert. +/// The ISO 4217 code of the target currency to which to convert. +/// The exchange rate value indicating how much of the target currency equals one +/// unit of the source currency. +public record ExchangeRateDto(string SourceCurrency, string TargetCurrency, decimal Value); diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Endpoints/ExchangeRateEndpoints.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Endpoints/ExchangeRateEndpoints.cs new file mode 100644 index 0000000000..edef9203cc --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Endpoints/ExchangeRateEndpoints.cs @@ -0,0 +1,59 @@ +using ExchangeRateProvider.Api.Constants; +using ExchangeRateProvider.Api.Dto; +using ExchangeRateProvider.Application.Interfaces; +using ExchangeRateProvider.Domain.Entities; + +namespace ExchangeRateProvider.Api.Endpoints; + +/// +/// Contains extension methods for mapping exchange rate related API endpoints. +/// +public static class ExchangeRateEndpoints +{ + /// + /// Maps the GET /exchange-rates endpoint to the application's route builder. + /// The endpoint requires authentication, applies rate limiting, and returns the latest exchange rates + /// for comma-separated currency codes provided via query parameter. + /// + /// The endpoint route builder to add the exchange rate endpoints to. + /// The endpoint route builder for method chaining. + public static IEndpointRouteBuilder MapExchangeRatesEndpoints(this IEndpointRouteBuilder app) + { + app.MapGet("/exchange-rates", + async ( + IExchangeRateProviderService exchangeRateProvider, + ILogger logger, + string? currencies, + CancellationToken cancellationToken) => + { + if (string.IsNullOrWhiteSpace(currencies)) + { + logger.LogWarning("Exchange rates request rejected: currencies parameter is missing or empty"); + return Results.BadRequest("Query string parameter 'currencies' is required. Example: ?currencies=USD,EUR"); + } + + var currencyList = currencies + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(code => new Currency(code)); + + var exchangeRates = await exchangeRateProvider + .GetLatestAsync(currencyList, cancellationToken); + + var response = exchangeRates.Select(r => + new ExchangeRateDto(r.SourceCurrency.Code, r.TargetCurrency.Code, r.Value)); + + return Results.Ok(response); + }) + .WithName("GetLatestExchangeRates") + .WithSummary("Get latest exchange rates") + .WithDescription("Retrieves the latest exchange rates for the specified currencies") + .Produces>() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status400BadRequest) + .Produces(StatusCodes.Status500InternalServerError) + .RequireAuthorization() + .RequireRateLimiting(RateLimitPolicies.ExchangeRates); + + return app; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj new file mode 100644 index 0000000000..18a221c18b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/ExchangeRateProvider.Api.csproj @@ -0,0 +1,27 @@ + + + + 352a7018-e59f-432d-bf94-d04e0e325564 + Linux + + + + + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/ExchangeRateProvider.http b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/ExchangeRateProvider.http new file mode 100644 index 0000000000..9e79902adc --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/ExchangeRateProvider.http @@ -0,0 +1,20 @@ +@ExchangeRateProvider_HostAddress = http://localhost:5078 +@AccessToken = your_access_token_here + +### Get Latest Exchange Rates +GET {{ExchangeRateProvider_HostAddress}}/exchange-rates?currencies=USD,EUR,GBP,XYZ +Accept: application/json +Authorization: Bearer {{AccessToken}} + +### Get Latest Exchange Rates - Single Currency +GET {{ExchangeRateProvider_HostAddress}}/exchange-rates?currencies=USD +Accept: application/json +Authorization: Bearer {{AccessToken}} + +### Get Latest Exchange Rates - Error Case (Missing currencies parameter) +GET {{ExchangeRateProvider_HostAddress}}/exchange-rates +Accept: application/json +Authorization: Bearer {{AccessToken}} + +### Health Check +GET {{ExchangeRateProvider_HostAddress}}/health diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Extensions/LoggingExtensions.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Extensions/LoggingExtensions.cs new file mode 100644 index 0000000000..0041c132f5 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Extensions/LoggingExtensions.cs @@ -0,0 +1,37 @@ +using ExchangeRateProvider.Api.Middleware; +using Serilog; + +namespace ExchangeRateProvider.Api.Extensions; + +/// +/// Extension methods for configuring logging in the application. +/// +public static class LoggingExtensions +{ + /// + /// Configures Serilog as the logging provider for the application. + /// + /// The web application builder. + /// The web application builder for method chaining. + public static WebApplicationBuilder AddSerilogLogging(this WebApplicationBuilder builder) + { + Log.Logger = new LoggerConfiguration() + .ReadFrom.Configuration(builder.Configuration) + .CreateLogger(); + + builder.Host.UseSerilog(); + + return builder; + } + + /// + /// Adds request/response logging middleware to the application pipeline. + /// + /// The web application. + /// The web application for method chaining. + public static WebApplication UseRequestResponseLogging(this WebApplication app) + { + app.UseMiddleware(); + return app; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Extensions/RateLimitingExtensions.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Extensions/RateLimitingExtensions.cs new file mode 100644 index 0000000000..271019cea4 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Extensions/RateLimitingExtensions.cs @@ -0,0 +1,47 @@ +using ExchangeRateProvider.Api.Constants; +using System.Threading.RateLimiting; + +namespace ExchangeRateProvider.Api.Extensions; + +/// +/// Provides extension methods for configuring rate limiting for the exchange rates API. +/// +public static class RateLimitingExtensions +{ + /// + /// Adds rate limiting configuration for exchange rates endpoints with token bucket algorithm. + /// Uses client-based limiting for authenticated requests (via 'azp' or 'sub' claims) and IP-based + /// limiting for anonymous requests. Allows 60 requests per minute with no queuing, returning + /// HTTP 429 when limit is exceeded. + /// + /// The service collection to add rate limiting services to. + /// The service collection for method chaining. + public static IServiceCollection AddExchangeRatesRateLimiting(this IServiceCollection services) + { + services.AddRateLimiter(options => + { + options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; + + options.AddPolicy(RateLimitPolicies.ExchangeRates, context => + { + var clientId = context.User.FindFirst("azp")?.Value ?? + context.User.FindFirst("sub")?.Value; + + var key = !string.IsNullOrWhiteSpace(clientId) + ? $"client:{clientId}" + : $"ip:{context.Connection.RemoteIpAddress}"; + + return RateLimitPartition.GetTokenBucketLimiter(key, _ => new TokenBucketRateLimiterOptions + { + TokenLimit = 60, + TokensPerPeriod = 60, + ReplenishmentPeriod = TimeSpan.FromMinutes(1), + AutoReplenishment = true, + QueueLimit = 0 + }); + }); + }); + + return services; + } +} diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Middleware/RequestResponseLoggingMiddleware.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Middleware/RequestResponseLoggingMiddleware.cs new file mode 100644 index 0000000000..7cd1650e99 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Middleware/RequestResponseLoggingMiddleware.cs @@ -0,0 +1,55 @@ +using System.Diagnostics; + +namespace ExchangeRateProvider.Api.Middleware; + +/// +/// Middleware for logging HTTP requests and responses with timing information. +/// +public class RequestResponseLoggingMiddleware(RequestDelegate next, ILogger logger) +{ + public async Task InvokeAsync(HttpContext context) + { + var stopwatch = Stopwatch.StartNew(); + var requestId = Activity.Current?.Id ?? Guid.NewGuid().ToString(); + + // Log request + using (logger.BeginScope(new Dictionary + { + ["RequestId"] = requestId, + ["Method"] = context.Request.Method, + ["Path"] = context.Request.Path, + ["QueryString"] = context.Request.QueryString.ToString(), + ["UserAgent"] = context.Request.Headers.UserAgent.ToString() + })) + { + logger.LogInformation("HTTP {Method} {Path}{QueryString} started", + context.Request.Method, + context.Request.Path, + context.Request.QueryString); + + try + { + await next(context); + + stopwatch.Stop(); + + logger.LogInformation("HTTP {Method} {Path} responded {StatusCode} in {ElapsedMilliseconds}ms", + context.Request.Method, + context.Request.Path, + context.Response.StatusCode, + stopwatch.ElapsedMilliseconds); + } + catch (Exception ex) + { + stopwatch.Stop(); + + logger.LogError(ex, "HTTP {Method} {Path} failed after {ElapsedMilliseconds}ms", + context.Request.Method, + context.Request.Path, + stopwatch.ElapsedMilliseconds); + + throw; + } + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Program.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Program.cs new file mode 100644 index 0000000000..e7e3663f18 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Program.cs @@ -0,0 +1,59 @@ +using ExchangeRateProvider.Api.Authentication; +using ExchangeRateProvider.Api.Endpoints; +using ExchangeRateProvider.Api.Extensions; +using ExchangeRateProvider.Application; +using ExchangeRateProvider.Infrastructure; +using Scalar.AspNetCore; +using Serilog; + +var builder = WebApplication.CreateBuilder(args); + +builder.AddSerilogLogging(); + +try +{ + Log.Information("Starting Exchange Rate Provider API..."); + Log.Information("Environment: {Environment}", builder.Environment.EnvironmentName); + + builder.Services + .AddOpenApi() + .AddAuth0Authentication(builder.Configuration) + .AddAuthorization() + .AddExchangeRatesRateLimiting() + .AddApplicationServices() + .AddInfrastructureServices(builder.Configuration); + + builder.Services.AddHealthChecks(); + + var app = builder.Build(); + + app.UseRequestResponseLogging(); + + app.UseAuthentication(); + app.UseAuthorization(); + + if (app.Environment.IsDevelopment()) + { + app.MapOpenApi(); + app.MapScalarApiReference(); + + Log.Information("Development mode: OpenAPI and Scalar documentation enabled"); + } + + app.UseHttpsRedirection(); + + app.MapExchangeRatesEndpoints(); + app.MapHealthChecks("/health"); + + Log.Information("Exchange Rate Provider API configured successfully"); + + app.Run(); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly"); +} +finally +{ + Log.CloseAndFlush(); +} diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Properties/launchSettings.json b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Properties/launchSettings.json new file mode 100644 index 0000000000..0bd145e880 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/Properties/launchSettings.json @@ -0,0 +1,31 @@ +{ + "profiles": { + "http": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "http://localhost:5078" + }, + "https": { + "commandName": "Project", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + }, + "dotnetRunMessages": true, + "applicationUrl": "https://localhost:7039;http://localhost:5078" + }, + "Container (Dockerfile)": { + "commandName": "Docker", + "launchUrl": "{Scheme}://{ServiceHost}:{ServicePort}", + "environmentVariables": { + "ASPNETCORE_HTTPS_PORTS": "8081", + "ASPNETCORE_HTTP_PORTS": "8080" + }, + "publishAllPorts": true, + "useSSL": true + } + }, + "$schema": "https://json.schemastore.org/launchsettings.json" +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/appsettings.Development.json b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/appsettings.Development.json new file mode 100644 index 0000000000..a26b5863e8 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/appsettings.Development.json @@ -0,0 +1,27 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Serilog": { + "MinimumLevel": { + "Default": "Debug", + "Override": { + "Microsoft": "Information", + "Microsoft.AspNetCore": "Information", + "System": "Information" + } + } + }, + "Auth0": { + "Authority": "https://dev-ysubdf4h0li5mmwc.us.auth0.com/", + "Audience": "exchange-rate-api" + }, + "CnbApi": { + "HttpClientName": "CnbApiHttpClient", + "BaseUrl": "https://api.cnb.cz", + "DailyExchangeRatesEndpoint": "/cnbapi/exrates/daily" + } +} diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/appsettings.Production.json b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/appsettings.Production.json new file mode 100644 index 0000000000..db30e3f634 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/appsettings.Production.json @@ -0,0 +1,27 @@ +{ + "Serilog": { + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.AspNetCore": "Warning", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", + "Microsoft.AspNetCore.Routing.EndpointMiddleware": "Warning", + "System": "Warning", + "ExchangeRateProvider": "Information" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "formatter": "Serilog.Formatting.Json.JsonFormatter, Serilog" + } + } + ], + "Enrich": [ "FromLogContext", "WithMachineName", "WithProcessId", "WithThreadId" ], + "Properties": { + "Application": "ExchangeRateProvider.Api" + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/appsettings.json b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/appsettings.json new file mode 100644 index 0000000000..7cddd132f0 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Api/appsettings.json @@ -0,0 +1,32 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "Serilog": { + "Using": [ "Serilog.Sinks.Console" ], + "MinimumLevel": { + "Default": "Information", + "Override": { + "Microsoft": "Warning", + "Microsoft.AspNetCore": "Warning", + "Microsoft.AspNetCore.Hosting.Diagnostics": "Information", + "Microsoft.AspNetCore.Routing.EndpointMiddleware": "Warning", + "Microsoft.AspNetCore.Mvc.Infrastructure.DefaultActionDescriptorCollectionProvider": "Warning", + "System": "Warning" + } + }, + "WriteTo": [ + { + "Name": "Console", + "Args": { + "outputTemplate": "[{Timestamp:HH:mm:ss} {Level:u3}] {Message:lj} {Properties:j}{NewLine}{Exception}" + } + } + ], + "Enrich": [ "FromLogContext", "WithMachineName", "WithProcessId", "WithThreadId" ] + }, + "AllowedHosts": "*" +} diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Application/ExchangeRateProvider.Application.csproj b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Application/ExchangeRateProvider.Application.csproj new file mode 100644 index 0000000000..fffa221614 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Application/ExchangeRateProvider.Application.csproj @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Application/Interfaces/IExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Application/Interfaces/IExchangeRateProvider.cs new file mode 100644 index 0000000000..da58ea3711 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Application/Interfaces/IExchangeRateProvider.cs @@ -0,0 +1,18 @@ +using ExchangeRateProvider.Domain.Entities; + +namespace ExchangeRateProvider.Application.Interfaces; + +/// +/// Defines the contract for exchange rate providers that can retrieve exchange rate data +/// from external sources. +/// +public interface IExchangeRateProvider +{ + /// + /// Retrieves the latest exchange rates from the external data source. + /// + /// Token to monitor for cancellation requests. + /// A task that represents the asynchronous operation. The task result contains + /// a read-only list of exchange rates. + Task> GetLatestAsync(CancellationToken cancellationToken = default); +} diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Application/Interfaces/IExchangeRateProviderService.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Application/Interfaces/IExchangeRateProviderService.cs new file mode 100644 index 0000000000..2896b5bdc2 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Application/Interfaces/IExchangeRateProviderService.cs @@ -0,0 +1,20 @@ +using ExchangeRateProvider.Domain.Entities; + +namespace ExchangeRateProvider.Application.Interfaces; + +/// +/// Defines the contract for the exchange rate provider service that orchestrates the retrieval of +/// exchange rates for specific currencies. +/// +public interface IExchangeRateProviderService +{ + /// + /// Retrieves the latest exchange rates for the specified currencies from available providers. + /// + /// The collection of currencies for which to retrieve exchange rates. + /// Token to monitor for cancellation requests. + /// A task that represents the asynchronous operation. The task result contains a read-only + /// list of exchange rates for the specified currencies. + Task> GetLatestAsync(IEnumerable currencies, + CancellationToken cancellationToken = default); +} diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Application/ServiceCollectionExtensions.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Application/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..86b472f07e --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Application/ServiceCollectionExtensions.cs @@ -0,0 +1,22 @@ +using ExchangeRateProvider.Application.Interfaces; +using Microsoft.Extensions.DependencyInjection; + +namespace ExchangeRateProvider.Application; + +/// +/// Provides extension methods for configuring application layer services in the dependency injection container. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers application layer services with the dependency injection container. + /// + /// The service collection to add application services to. + /// The service collection for method chaining. + public static IServiceCollection AddApplicationServices(this IServiceCollection services) + { + _ = services.AddScoped(); + + return services; + } +} diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Application/Services/ExchangeRateProviderService.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Application/Services/ExchangeRateProviderService.cs new file mode 100644 index 0000000000..96bf3dccc9 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Application/Services/ExchangeRateProviderService.cs @@ -0,0 +1,50 @@ +using ExchangeRateProvider.Application.Interfaces; +using ExchangeRateProvider.Domain.Entities; + +namespace ExchangeRateProvider.Application.Services; + +/// +/// Implementation of the exchange rate provider service that filters exchange rates by requested currencies. +/// +/// The underlying exchange rate provider for retrieving all available exchange rates. +public class ExchangeRateProviderService( + IExchangeRateProvider exchangeRateProvider) : IExchangeRateProviderService +{ + /// + /// Retrieves the latest exchange rates for the specified currencies by fetching all available + /// rates and filtering them. + /// + /// The collection of currencies for which to retrieve exchange rates. + /// Token to monitor for cancellation requests. + /// A task that represents the asynchronous operation. The task result contains a read-only + /// list of exchange rates filtered by the requested currencies. + public async Task> GetLatestAsync( + IEnumerable currencies, + CancellationToken cancellationToken = default) + { + var exchangeRates = await exchangeRateProvider.GetLatestAsync(cancellationToken); + + return FilterByCurrencies(exchangeRates, currencies); + } + + /// + /// Filters exchange rates to include only those whose source currency matches the requested currencies. + /// + /// The complete collection of exchange rates to filter. + /// The collection of currencies to filter by. + /// A list of exchange rates that match the requested source currencies. Returns an empty list + /// if no currencies are requested. + private static List FilterByCurrencies( + IEnumerable exchangeRates, + IEnumerable currencies) + { + var requested = new HashSet(currencies); + + if (requested.Count == 0) + { + return []; + } + + return [.. exchangeRates.Where(r => requested.Contains(r.SourceCurrency))]; + } +} diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.ConsoleApp/Dockerfile b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.ConsoleApp/Dockerfile new file mode 100644 index 0000000000..f928779952 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.ConsoleApp/Dockerfile @@ -0,0 +1,34 @@ +FROM mcr.microsoft.com/dotnet/runtime:10.0 AS base +USER $APP_UID +WORKDIR /app + +FROM mcr.microsoft.com/dotnet/sdk:10.0 AS build +ARG BUILD_CONFIGURATION=Release +WORKDIR /src + +# Copy Directory.Build.props first (contains TargetFramework) +COPY ["Directory.Build.props", "./"] + +# Copy all project files for dependency resolution +COPY ["src/ExchangeRateProvider.ConsoleApp/ExchangeRateProvider.ConsoleApp.csproj", "src/ExchangeRateProvider.ConsoleApp/"] +COPY ["src/ExchangeRateProvider.Application/ExchangeRateProvider.Application.csproj", "src/ExchangeRateProvider.Application/"] +COPY ["src/ExchangeRateProvider.Domain/ExchangeRateProvider.Domain.csproj", "src/ExchangeRateProvider.Domain/"] +COPY ["src/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj", "src/ExchangeRateProvider.Infrastructure/"] + +# Restore dependencies +RUN dotnet restore "src/ExchangeRateProvider.ConsoleApp/ExchangeRateProvider.ConsoleApp.csproj" + +# Copy all source code +COPY . . + +# Build the application +RUN dotnet build "src/ExchangeRateProvider.ConsoleApp/ExchangeRateProvider.ConsoleApp.csproj" -c $BUILD_CONFIGURATION -o /app/build + +FROM build AS publish +ARG BUILD_CONFIGURATION=Release +RUN dotnet publish "src/ExchangeRateProvider.ConsoleApp/ExchangeRateProvider.ConsoleApp.csproj" -c $BUILD_CONFIGURATION -o /app/publish /p:UseAppHost=false + +FROM base AS final +WORKDIR /app +COPY --from=publish /app/publish . +ENTRYPOINT ["dotnet", "ExchangeRateProvider.ConsoleApp.dll"] \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.ConsoleApp/Dto/ExchangeRateDto.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.ConsoleApp/Dto/ExchangeRateDto.cs new file mode 100644 index 0000000000..8781a5132e --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.ConsoleApp/Dto/ExchangeRateDto.cs @@ -0,0 +1,20 @@ +using ExchangeRateProvider.Domain.Entities; + +namespace ExchangeRateProvider.ConsoleApp.Dto; + +/// +/// Data Transfer Object representing an exchange rate for console application output. +/// +/// The source currency from which to convert. +/// The target currency to which to convert. +/// The exchange rate value indicating how much of the target currency +/// equals one unit of the source currency. +public record ExchangeRateDto(Currency SourceCurrency, Currency TargetCurrency, decimal Value) +{ + /// + /// Returns a formatted string representation of the exchange rate in the format + /// "SourceCurrency/TargetCurrency=Value". + /// + /// A string in the format "SourceCurrency/TargetCurrency=Value". + public override string ToString() => $"{SourceCurrency}/{TargetCurrency}={Value}"; +} diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.ConsoleApp/ExchangeRateProvider.ConsoleApp.csproj b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.ConsoleApp/ExchangeRateProvider.ConsoleApp.csproj new file mode 100644 index 0000000000..fc18da620f --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.ConsoleApp/ExchangeRateProvider.ConsoleApp.csproj @@ -0,0 +1,28 @@ + + + + Exe + Linux + ..\.. + + + + + PreserveNewest + true + PreserveNewest + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.ConsoleApp/ExchangeRateUpdaterWorker.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.ConsoleApp/ExchangeRateUpdaterWorker.cs new file mode 100644 index 0000000000..0328469985 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.ConsoleApp/ExchangeRateUpdaterWorker.cs @@ -0,0 +1,64 @@ +using ExchangeRateProvider.Application.Interfaces; +using ExchangeRateProvider.ConsoleApp.Dto; +using ExchangeRateProvider.Domain.Entities; +using Microsoft.Extensions.Hosting; + +namespace ExchangeRateProvider.ConsoleApp; + +/// +/// Background service that retrieves and displays exchange rates for predefined currencies, +/// then stops the application. +/// +/// Service for retrieving exchange rates. +/// Host application lifetime for controlling application shutdown. +public class ExchangeRateUpdaterWorker( + IExchangeRateProviderService exchangeRateProvider, + IHostApplicationLifetime lifetime) + : BackgroundService +{ + /// + /// Executes the background service by retrieving exchange rates for a predefined set of + /// currencies and displaying them to the console. + /// + /// Token to monitor for cancellation requests. + /// A task that represents the asynchronous operation. + protected override async Task ExecuteAsync(CancellationToken cancellationToken) + { + try + { + var 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") + }; + + var exchangeRates = (await exchangeRateProvider + .GetLatestAsync(currencies, cancellationToken)).ToList(); + + var exchangeRatesDto = exchangeRates + .Select(r => new ExchangeRateDto(r.SourceCurrency, r.TargetCurrency, r.Value)) + .ToList(); + + Console.WriteLine($"Successfully retrieved {exchangeRatesDto.Count} exchange rates:"); + exchangeRatesDto.ForEach(Console.WriteLine); + } + catch (Exception ex) + { + Console.WriteLine($"Could not retrieve exchange rates: '{ex.Message}'."); + Environment.ExitCode = 1; + } + finally + { + lifetime.StopApplication(); + + Console.ReadLine(); + } + } +} diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.ConsoleApp/Program.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.ConsoleApp/Program.cs new file mode 100644 index 0000000000..ffef3dd889 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.ConsoleApp/Program.cs @@ -0,0 +1,19 @@ +using ExchangeRateProvider.Application; +using ExchangeRateProvider.ConsoleApp; +using ExchangeRateProvider.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging; + +var builder = Host.CreateApplicationBuilder(args); + +builder.Logging.ClearProviders(); + +builder.Services + .AddApplicationServices() + .AddInfrastructureServices(builder.Configuration); + +builder.Services.AddHostedService(); + +using var host = builder.Build(); +await host.RunAsync(); diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.ConsoleApp/Properties/launchSettings.json b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.ConsoleApp/Properties/launchSettings.json new file mode 100644 index 0000000000..29b8a5dd0e --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.ConsoleApp/Properties/launchSettings.json @@ -0,0 +1,10 @@ +{ + "profiles": { + "ExchangeRateProvider.ConsoleApp": { + "commandName": "Project" + }, + "Container (Dockerfile)": { + "commandName": "Docker" + } + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.ConsoleApp/appsettings.json b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.ConsoleApp/appsettings.json new file mode 100644 index 0000000000..f695aa3aca --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.ConsoleApp/appsettings.json @@ -0,0 +1,7 @@ +{ + "CnbApi": { + "HttpClientName": "CnbApiHttpClient", + "BaseUrl": "https://api.cnb.cz", + "DailyExchangeRatesEndpoint": "/cnbapi/exrates/daily" + } +} diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Domain/Entities/Currency.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Domain/Entities/Currency.cs new file mode 100644 index 0000000000..a2e37f370c --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Domain/Entities/Currency.cs @@ -0,0 +1,22 @@ +namespace ExchangeRateProvider.Domain.Entities; + +/// +/// Represents a currency with its ISO 4217 code. +/// +/// The three-letter ISO 4217 currency code. +public record Currency(string Code) +{ + /// + /// Three-letter ISO 4217 code of the currency. + /// + public string Code { get; } = Code; + + /// + /// Returns the currency code as a string representation. + /// + /// The ISO 4217 currency code. + public override string ToString() + { + return Code; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Domain/Entities/ExchangeRate.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Domain/Entities/ExchangeRate.cs new file mode 100644 index 0000000000..03275a97c4 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Domain/Entities/ExchangeRate.cs @@ -0,0 +1,11 @@ +namespace ExchangeRateProvider.Domain.Entities; + +/// +/// Represents an exchange rate between two currencies. +/// +/// The source currency from which to convert. +/// The target currency to which to convert. +/// The exchange rate value indicating how much of the target currency equals one +/// unit of the source currency. +/// The date for which this exchange rate is valid. +public record ExchangeRate(Currency SourceCurrency, Currency TargetCurrency, decimal Value, DateOnly ValidFor); diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Domain/ExchangeRateProvider.Domain.csproj b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Domain/ExchangeRateProvider.Domain.csproj new file mode 100644 index 0000000000..5adef81ec7 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Domain/ExchangeRateProvider.Domain.csproj @@ -0,0 +1,6 @@ + + + + + + diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj new file mode 100644 index 0000000000..fe321cf9a8 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProvider.Infrastructure.csproj @@ -0,0 +1,25 @@ + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/Cnb/CnbApiOptions.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/Cnb/CnbApiOptions.cs new file mode 100644 index 0000000000..c8e4ff4645 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/Cnb/CnbApiOptions.cs @@ -0,0 +1,27 @@ +namespace ExchangeRateProvider.Infrastructure.ExchangeRateProviders.Cnb; + +/// +/// Configuration options for the Czech National Bank (CNB) API integration. +/// +public record CnbApiOptions +{ + /// + /// The configuration section name for CNB API options. + /// + public const string SectionName = "CnbApi"; + + /// + /// The name of the HTTP client to use for CNB API requests. + /// + public required string HttpClientName { get; init; } + + /// + /// The base URL for the CNB API. + /// + public required string BaseUrl { get; init; } + + /// + /// The endpoint path for retrieving daily exchange rates from the CNB API. + /// + public required string DailyExchangeRatesEndpoint { get; init; } +} diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/Cnb/CnbCacheDateTimeProvider.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/Cnb/CnbCacheDateTimeProvider.cs new file mode 100644 index 0000000000..f4b3c437da --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/Cnb/CnbCacheDateTimeProvider.cs @@ -0,0 +1,98 @@ +namespace ExchangeRateProvider.Infrastructure.ExchangeRateProviders.Cnb; + +/// +/// Implementation of date and time calculation services for CNB (Czech National Bank) exchange rate operations. +/// +/// The time provider to use for current time calculations. +internal class CnbCacheDateTimeProvider(TimeProvider timeProvider) : ICacheDateTimeProvider +{ + /// + /// Default buffer time added after the CNB fixing time before cache expiration. + /// + private static readonly TimeSpan DefaultBuffer = TimeSpan.FromMinutes(5); + + /// + /// The time when CNB publishes exchange rates (14:30 Prague time on business days). + /// + private static readonly TimeOnly FixingTime = new(14, 30, 0); + + /// + /// Calculates the UTC expiration time for cached exchange rates based on CNB's fixing schedule. + /// CNB publishes exchange rates at 14:30 Prague time on business days. + /// + /// Additional buffer time after the CNB fixing time. Defaults to 5 minutes if not specified. + /// The UTC DateTime when the cached exchange rates should expire. + public DateTimeOffset GetNextFixingExpirationUtc(TimeSpan? buffer = null) + { + buffer ??= DefaultBuffer; + + var pragueTz = TimeZoneInfo.FindSystemTimeZoneById("Europe/Prague"); + + var nowUtc = timeProvider.GetUtcNow(); + var nowPrague = TimeZoneInfo.ConvertTime(nowUtc, pragueTz); + + var todayPrague = DateOnly.FromDateTime(nowPrague.DateTime); + + var fixingTodayPrague = new DateTimeOffset( + todayPrague.Year, todayPrague.Month, todayPrague.Day, + FixingTime.Hour, FixingTime.Minute, FixingTime.Second, + nowPrague.Offset); + + DateTimeOffset nextFixingPrague; + + if (ShouldUseNextBusinessDayFixing(todayPrague, nowPrague, fixingTodayPrague)) + { + var nextBusinessDay = GetNextBusinessDay(todayPrague); + + nextFixingPrague = new DateTimeOffset( + nextBusinessDay.Year, nextBusinessDay.Month, nextBusinessDay.Day, + FixingTime.Hour, FixingTime.Minute, FixingTime.Second, + nowPrague.Offset); + } + else + { + nextFixingPrague = fixingTodayPrague; + } + + var expirePrague = nextFixingPrague.Add(buffer.Value); + return TimeZoneInfo.ConvertTime(expirePrague, TimeZoneInfo.Utc); + } + + /// + /// Determines if the calculation should use the next business day fixing time. + /// This occurs when it's currently a weekend or when today's fixing time has already passed. + /// + /// The current date in Prague timezone. + /// The current time in Prague timezone. + /// Today's fixing time in Prague timezone. + /// True if the next business day fixing should be used; otherwise, false. + private static bool ShouldUseNextBusinessDayFixing(DateOnly date, DateTimeOffset currentTime, DateTimeOffset todayFixingTime) + { + return IsWeekend(date) || currentTime >= todayFixingTime; + } + + /// + /// Determines if the specified date falls on a weekend (Saturday or Sunday). + /// + /// The date to check. + /// True if the date is a Saturday or Sunday; otherwise, false. + private static bool IsWeekend(DateOnly date) => + date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday; + + /// + /// Finds the next business day (Monday through Friday) after the specified date. + /// + /// The starting date to find the next business day from. + /// The next business day after the specified date. + private static DateOnly GetNextBusinessDay(DateOnly date) + { + var nextBusinessDay = date.AddDays(1); + + while (IsWeekend(nextBusinessDay)) + { + nextBusinessDay = nextBusinessDay.AddDays(1); + } + + return nextBusinessDay; + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/Cnb/CnbCachedExchangeRateProviderDecorator.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/Cnb/CnbCachedExchangeRateProviderDecorator.cs new file mode 100644 index 0000000000..080f66204b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/Cnb/CnbCachedExchangeRateProviderDecorator.cs @@ -0,0 +1,114 @@ +using ExchangeRateProvider.Application.Interfaces; +using ExchangeRateProvider.Domain.Entities; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateProvider.Infrastructure.ExchangeRateProviders.Cnb; + +/// +/// Decorator that adds caching capabilities to an exchange rate provider, specifically optimized +/// for CNB (Czech National Bank) data patterns. The cache expires based on CNB's fixing schedule +/// (14:30 Prague time on business days) with a configurable buffer. +/// +/// The underlying exchange rate provider to decorate with caching. +/// The memory cache instance for storing exchange rate data. +/// The date and time provider for CNB-specific calculations. +/// Logger for tracking cache operations. +internal class CnbCachedExchangeRateProviderDecorator( + IExchangeRateProvider inner, + IMemoryCache cache, + ICacheDateTimeProvider dateTimeProvider, + ILogger logger) + : IExchangeRateProvider +{ + /// + /// Cache key for storing the latest valid-for date string. + /// + private const string LatestValidForKey = "ExchangeRates:LatestValidFor"; + + /// + /// Prefix for cache keys that store exchange rates by their valid-for date. + /// + private const string RatesKeyPrefix = "ExchangeRates:ValidFor:"; + + /// + /// Cache key for the loader lock to prevent concurrent loading of the same data. + /// + private const string LoaderKey = "ExchangeRates:LatestValid:Loader"; + + /// + /// Default buffer time added after the CNB fixing time before cache expiration. + /// + private static readonly TimeSpan DefaultBuffer = TimeSpan.FromMinutes(5); + + /// + /// Retrieves the latest exchange rates from cache if available, or loads them from the underlying provider. + /// Cache expiration is based on CNB's fixing schedule (14:30 Prague time on business days). + /// + /// Token to monitor for cancellation requests. + /// A task that represents the asynchronous operation. The task result contains a read-only list of cached + /// or freshly loaded exchange rates. + public async Task> GetLatestAsync(CancellationToken cancellationToken = default) + { + logger.LogDebug("Attempting to retrieve exchange rates from cache"); + + if (cache.TryGetValue(LatestValidForKey, out var validForString) && + !string.IsNullOrWhiteSpace(validForString)) + { + var ratesKey = RatesKeyPrefix + validForString; + + if (cache.TryGetValue>(ratesKey, out var cachedRates) && + cachedRates is { Count: > 0 }) + { + logger.LogDebug("Cache hit: Retrieved {CachedRatesCount} exchange rates from cache for date {ValidFor}", + cachedRates.Count, validForString); + + return cachedRates; + } + } + + logger.LogDebug("Loading exchange rates from underlying provider with cache loader"); + + var loaded = await cache.GetOrCreateAsync>(LoaderKey, async entry => + { + var absoluteExpirationUtc = dateTimeProvider.GetNextFixingExpirationUtc(DefaultBuffer); + entry.AbsoluteExpiration = absoluteExpirationUtc; + + logger.LogDebug("Cache expiration set to: {ExpirationTime} UTC", absoluteExpirationUtc); + + var rates = await inner.GetLatestAsync(cancellationToken); + if (rates.Count == 0) + { + logger.LogWarning("Underlying provider returned no exchange rates"); + return []; + } + + var validFor = rates[0].ValidFor; + var validForKeyPart = validFor.ToString("yyyy-MM-dd"); + + logger.LogDebug("Loaded {RatesCount} exchange rates from provider, valid for: {ValidFor}", + rates.Count, validFor); + + var ratesKey = RatesKeyPrefix + validForKeyPart; + + // Cache the rates + cache.Set( + ratesKey, + rates, + new MemoryCacheEntryOptions { AbsoluteExpiration = absoluteExpirationUtc }); + + // Cache the latest valid-for date + cache.Set( + LatestValidForKey, + validForKeyPart, + new MemoryCacheEntryOptions { AbsoluteExpiration = absoluteExpirationUtc }); + + logger.LogDebug("Successfully cached {RatesCount} exchange rates with key: {CacheKey}, expires: {ExpirationTime}", + rates.Count, ratesKey, absoluteExpirationUtc); + + return rates; + }); + + return loaded ?? []; + } +} diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/Cnb/CnbExchangeRate.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/Cnb/CnbExchangeRate.cs new file mode 100644 index 0000000000..3fcae962ac --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/Cnb/CnbExchangeRate.cs @@ -0,0 +1,42 @@ +namespace ExchangeRateProvider.Infrastructure.ExchangeRateProviders.Cnb; + +/// +/// Represents an exchange rate data structure as returned by the Czech National Bank (CNB) API. +/// +internal record CnbExchangeRate +{ + /// + /// The amount of the foreign currency that corresponds to the exchange rate. + /// + public int Amount { get; init; } + + /// + /// The country associated with the currency. + /// + public required string Country { get; init; } + + /// + /// The full name of the currency. + /// + public required string Currency { get; init; } + + /// + /// The three-letter ISO 4217 currency code. + /// + public required string CurrencyCode { get; init; } + + /// + /// The order number used by CNB for sorting currencies. + /// + public int Order { get; init; } + + /// + /// The exchange rate value in Czech koruna (CZK) for the specified amount of foreign currency. + /// + public decimal Rate { get; init; } + + /// + /// The date and time for which this exchange rate is valid. + /// + public DateOnly ValidFor { get; init; } +} diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/Cnb/CnbExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/Cnb/CnbExchangeRateProvider.cs new file mode 100644 index 0000000000..eabd68f7b8 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/Cnb/CnbExchangeRateProvider.cs @@ -0,0 +1,85 @@ +using ExchangeRateProvider.Application.Interfaces; +using ExchangeRateProvider.Domain.Entities; +using Microsoft.Extensions.Options; +using System.Net.Http.Json; +using Microsoft.AspNetCore.WebUtilities; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateProvider.Infrastructure.ExchangeRateProviders.Cnb; + +/// +/// Exchange rate provider that retrieves exchange rate data from the Czech National Bank (CNB) API. +/// All exchange rates are provided with Czech Koruna (CZK) as the target currency. +/// +/// Configuration options for the CNB API integration. +/// Factory for creating HTTP clients to make API requests. +/// Logger for tracking API operations. +public class CnbExchangeRateProvider( + IOptions cnbApiOptions, + IHttpClientFactory httpClientFactory, + ILogger logger) : IExchangeRateProvider +{ + /// + /// Retrieves the latest exchange rates from the Czech National Bank API. + /// The rates are converted to have Czech Koruna (CZK) as the target currency and normalized per unit of source currency. + /// + /// Token to monitor for cancellation requests. + /// A task that represents the asynchronous operation. The task result contains a read-only list + /// of exchange rates with CZK as the target currency. + public async Task> GetLatestAsync(CancellationToken cancellationToken = default) + { + var httpClient = httpClientFactory.CreateClient(cnbApiOptions.Value.HttpClientName); + + var url = QueryHelpers.AddQueryString( + cnbApiOptions.Value.DailyExchangeRatesEndpoint, + "lang", "EN"); + + logger.LogDebug("Making request to CNB API: {Url}", url); + + try + { + var rates = await httpClient.GetFromJsonAsync(url, cancellationToken); + + if (rates is null) + { + logger.LogWarning("CNB API returned null or empty response"); + throw new InvalidOperationException("CNB API returned null or empty response"); + } + + logger.LogDebug("Successfully retrieved {RatesCount} rates from CNB API, valid for: {ValidFor}", + rates.Rates.Count(), + rates.Rates.FirstOrDefault()?.ValidFor.ToString()); + + var target = new Currency("CZK"); + + var result = rates.Rates + .Select(r => + { + var source = new Currency(r.CurrencyCode); + var value = r.Rate / r.Amount; + var validFor = r.ValidFor; + + return new ExchangeRate(source, target, value, validFor); + }) + .ToList() + .AsReadOnly(); + + return result; + } + catch (HttpRequestException ex) + { + logger.LogError(ex, "HTTP error occurred while fetching exchange rates from CNB API: {Url}", url); + throw; + } + catch (TaskCanceledException ex) + { + logger.LogWarning(ex, "Request to CNB API was cancelled or timed out: {Url}", url); + throw; + } + catch (Exception ex) + { + logger.LogError(ex, "Unexpected error occurred while fetching exchange rates from CNB API: {Url}", url); + throw; + } + } +} diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/Cnb/CnbExchangeRates.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/Cnb/CnbExchangeRates.cs new file mode 100644 index 0000000000..1a5a0b7609 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/Cnb/CnbExchangeRates.cs @@ -0,0 +1,12 @@ +namespace ExchangeRateProvider.Infrastructure.ExchangeRateProviders.Cnb; + +/// +/// Represents a collection of exchange rates as returned by the Czech National Bank (CNB) API. +/// +internal record CnbExchangeRates +{ + /// + /// A collection of exchange rate entries from the CNB API response. + /// + public required IEnumerable Rates { get; init; } +} diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/HttpClientBuilderExtensions.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/HttpClientBuilderExtensions.cs new file mode 100644 index 0000000000..e1c39d7056 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/HttpClientBuilderExtensions.cs @@ -0,0 +1,76 @@ +using System.Net; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Http.Resilience; +using Polly; +using Polly.Retry; + +namespace ExchangeRateProvider.Infrastructure.ExchangeRateProviders; + +/// +/// Provides extension methods for adding resilience patterns to HTTP clients. +/// +internal static class HttpClientBuilderExtensions +{ + /// + /// Adds a resilience pipeline to the HTTP client with timeout, retry, and circuit breaker policies. + /// The pipeline includes timeout, exponential backoff retry strategy, and circuit breaker policies + /// with values defined in . + /// + /// The HTTP client builder to add resilience to. + /// The name of the resilience handler for identification purposes. + /// The HTTP resilience pipeline builder for further configuration. + internal static IHttpResiliencePipelineBuilder AddResilience(this IHttpClientBuilder builder, string name) + { + return builder.AddResilienceHandler(name, pipeline => + { + pipeline.AddTimeout(ResilienceConfiguration.Timeout); + + pipeline.AddRetry(new RetryStrategyOptions + { + MaxRetryAttempts = ResilienceConfiguration.MaxRetryAttempts, + Delay = ResilienceConfiguration.InitialRetryDelay, + BackoffType = DelayBackoffType.Exponential, + UseJitter = ResilienceConfiguration.UseJitter, + ShouldHandle = CreateRetryPredicateBuilder() + }); + + pipeline.AddCircuitBreaker(new Polly.CircuitBreaker.CircuitBreakerStrategyOptions + { + SamplingDuration = ResilienceConfiguration.CircuitBreakerSamplingDuration, + FailureRatio = ResilienceConfiguration.CircuitBreakerFailureRatio, + MinimumThroughput = ResilienceConfiguration.CircuitBreakerMinimumThroughput, + BreakDuration = ResilienceConfiguration.CircuitBreakerBreakDuration, + ShouldHandle = CreateCircuitBreakerPredicateBuilder() + }); + }); + } + + /// + /// Creates the predicate builder for determining which conditions should trigger retry attempts. + /// + /// A predicate builder configured for retry scenarios. + private static PredicateBuilder CreateRetryPredicateBuilder() + { + return new PredicateBuilder() + .Handle() + .Handle() + .HandleResult(r => + r.StatusCode == HttpStatusCode.RequestTimeout || + r.StatusCode == HttpStatusCode.TooManyRequests || + (int)r.StatusCode >= 500); + } + + /// + /// Creates the predicate builder for determining which conditions should be counted as failures by the circuit breaker. + /// + /// A predicate builder configured for circuit breaker scenarios. + private static PredicateBuilder CreateCircuitBreakerPredicateBuilder() + { + return new PredicateBuilder() + .Handle() + .Handle() + .HandleResult(r => + (int)r.StatusCode >= 500 || + r.StatusCode == HttpStatusCode.TooManyRequests); + } +} diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/ICacheDateTimeProvider.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/ICacheDateTimeProvider.cs new file mode 100644 index 0000000000..1af453f96b --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/ICacheDateTimeProvider.cs @@ -0,0 +1,20 @@ +namespace ExchangeRateProvider.Infrastructure.ExchangeRateProviders; + +/// +/// Defines the contract for cache date/time providers that calculate expiration times for cached exchange rates. +/// This interface abstracts the logic for determining when cached exchange rate data should expire based +/// on specific provider fixing schedules (e.g., CNB publishes at 14:30 Prague time on business days). +/// +internal interface ICacheDateTimeProvider +{ + /// + /// Calculates the UTC expiration time for cached exchange rates based on the provider's fixing schedule. + /// The implementation should consider the provider's specific publication schedule (business days, time zones, etc.) + /// to determine the optimal cache expiration time. + /// + /// Additional buffer time after the fixing time to account for potential delays or processing time. + /// If null, the implementation should use a sensible default buffer. + /// The UTC DateTime when the cached exchange rates should expire. This ensures that fresh data + /// will be fetched after the provider's next scheduled update. + DateTimeOffset GetNextFixingExpirationUtc(TimeSpan? buffer = null); +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/ResilienceConfiguration.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/ResilienceConfiguration.cs new file mode 100644 index 0000000000..7e2d64b762 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ExchangeRateProviders/ResilienceConfiguration.cs @@ -0,0 +1,50 @@ +namespace ExchangeRateProvider.Infrastructure.ExchangeRateProviders; + +/// +/// Contains configuration constants for HTTP client resilience patterns. +/// These values define timeout, retry, and circuit breaker behavior for external API calls. +/// +internal static class ResilienceConfiguration +{ + /// + /// Maximum time to wait for a single HTTP request before timing out. + /// + public static readonly TimeSpan Timeout = TimeSpan.FromSeconds(10); + + /// + /// Maximum number of retry attempts for failed HTTP requests. + /// + public const int MaxRetryAttempts = 3; + + /// + /// Initial delay before the first retry attempt. + /// + public static readonly TimeSpan InitialRetryDelay = TimeSpan.FromMilliseconds(200); + + /// + /// Whether to use jitter in retry delays to avoid thundering herd problems. + /// + public const bool UseJitter = true; + + /// + /// Time window for sampling circuit breaker failure rate. + /// + public static readonly TimeSpan CircuitBreakerSamplingDuration = TimeSpan.FromSeconds(30); + + /// + /// Failure ratio threshold (0.0 to 1.0) that triggers circuit breaker to open. + /// 0.5 means 50% of requests must fail within the sampling duration. + /// + public const double CircuitBreakerFailureRatio = 0.5; + + /// + /// Minimum number of requests required before circuit breaker can open. + /// This prevents opening on a small number of requests. + /// + public const int CircuitBreakerMinimumThroughput = 10; + + /// + /// How long the circuit breaker stays open before attempting to close. + /// + public static readonly TimeSpan CircuitBreakerBreakDuration = TimeSpan.FromSeconds(30); +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ServiceCollectionExtensions.cs b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ServiceCollectionExtensions.cs new file mode 100644 index 0000000000..91f04ad6de --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/src/ExchangeRateProvider.Infrastructure/ServiceCollectionExtensions.cs @@ -0,0 +1,60 @@ +using ExchangeRateProvider.Application.Interfaces; +using ExchangeRateProvider.Infrastructure.ExchangeRateProviders; +using ExchangeRateProvider.Infrastructure.ExchangeRateProviders.Cnb; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace ExchangeRateProvider.Infrastructure; + +/// +/// Provides extension methods for configuring infrastructure layer services in the dependency injection container. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Registers infrastructure layer services including exchange rate providers and HTTP clients with the dependency injection container. + /// Configures the CNB (Czech National Bank) exchange rate provider with its associated HTTP client and options. + /// + /// The service collection to add infrastructure services to. + /// The configuration containing settings for infrastructure services, including CNB API options. + /// The service collection for method chaining. + public static IServiceCollection AddInfrastructureServices( + this IServiceCollection services, + IConfiguration configuration) + { + var cnbApiOptionsSection = configuration.GetSection(CnbApiOptions.SectionName); + var cnbApiOptions = cnbApiOptionsSection.Get()!; + + services.Configure(cnbApiOptionsSection); + + services.AddHttpClient( + cnbApiOptions.HttpClientName, client => + { + client.BaseAddress = new Uri(cnbApiOptions.BaseUrl); + client.DefaultRequestHeaders.Add("Accept", "application/json"); + }) + .AddResilience("cnb-resilience"); + + services.AddMemoryCache(); + + services.AddSingleton(TimeProvider.System); + + services.AddSingleton(); + + services.AddScoped(); + + services.AddScoped(serviceProvider => + { + var inner = serviceProvider.GetRequiredService(); + var cache = serviceProvider.GetRequiredService(); + var dateTimeProvider = serviceProvider.GetRequiredService(); + var logger = serviceProvider.GetRequiredService>(); + + return new CnbCachedExchangeRateProviderDecorator(inner, cache, dateTimeProvider, logger); + }); + + return services; + } +} diff --git a/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Api.Tests/Endpoints/ExchangeRateEndpointsTests.cs b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Api.Tests/Endpoints/ExchangeRateEndpointsTests.cs new file mode 100644 index 0000000000..bf1977b9f9 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Api.Tests/Endpoints/ExchangeRateEndpointsTests.cs @@ -0,0 +1,442 @@ +using ExchangeRateProvider.Api.Dto; +using ExchangeRateProvider.Api.Endpoints; +using ExchangeRateProvider.Application.Interfaces; +using ExchangeRateProvider.Domain.Entities; +using Microsoft.AspNetCore.Builder; +using Moq; + +namespace ExchangeRateProvider.Api.Tests.Endpoints; + +public class ExchangeRateEndpointsTests +{ + private readonly Mock _mockExchangeRateProviderService = new(); + + [Fact] + public async Task ExchangeRateEndpoint_WithValidCurrencies_ReturnsExchangeRates() + { + // Arrange + var usd = new Currency("USD"); + var eur = new Currency("EUR"); + var czk = new Currency("CZK"); + + var exchangeRates = new List + { + new(usd, czk, 25.5m, DateOnly.FromDateTime(DateTime.Today)), + new(eur, czk, 24.8m, DateOnly.FromDateTime(DateTime.Today)) + }; + + _mockExchangeRateProviderService + .Setup(x => x.GetLatestAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(exchangeRates); + + const string currencies = "USD,EUR"; + var cancellationToken = CancellationToken.None; + + // Act + var currencyList = currencies + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(code => new Currency(code)); + + var result = await _mockExchangeRateProviderService.Object + .GetLatestAsync(currencyList, cancellationToken); + + var response = result.Select(r => + new ExchangeRateDto(r.SourceCurrency.Code, r.TargetCurrency.Code, r.Value)); + + // Assert + Assert.NotNull(response); + Assert.Equal(2, response.Count()); + Assert.Contains(response, dto => dto is { SourceCurrency: "USD", Value: 25.5m }); + Assert.Contains(response, dto => dto is { SourceCurrency: "EUR", Value: 24.8m }); + } + + [Fact] + public async Task ExchangeRateEndpoint_WithSingleCurrency_ReturnsSingleRate() + { + // Arrange + var usd = new Currency("USD"); + var czk = new Currency("CZK"); + + var exchangeRates = new List + { + new(usd, czk, 25.5m, DateOnly.FromDateTime(DateTime.Today)) + }; + + _mockExchangeRateProviderService + .Setup(x => x.GetLatestAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(exchangeRates); + + const string currencies = "USD"; + var cancellationToken = CancellationToken.None; + + // Act + var currencyList = currencies + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(code => new Currency(code)); + + var result = await _mockExchangeRateProviderService.Object + .GetLatestAsync(currencyList, cancellationToken); + + var response = result.Select(r => + new ExchangeRateDto(r.SourceCurrency.Code, r.TargetCurrency.Code, r.Value)); + + // Assert + Assert.NotNull(response); + Assert.Single(response); + var dto = response.First(); + Assert.Equal("USD", dto.SourceCurrency); + Assert.Equal("CZK", dto.TargetCurrency); + Assert.Equal(25.5m, dto.Value); + } + + [Fact] + public void ExchangeRateEndpoint_CurrencyParsing_WithValidString_SplitsCorrectly() + { + // Arrange + const string currenciesString = "USD,EUR,GBP"; + + // Act + var currencyList = currenciesString + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(code => new Currency(code)) + .ToList(); + + // Assert + Assert.Equal(3, currencyList.Count); + Assert.Contains(currencyList, c => c.Code == "USD"); + Assert.Contains(currencyList, c => c.Code == "EUR"); + Assert.Contains(currencyList, c => c.Code == "GBP"); + } + + [Fact] + public void ExchangeRateEndpoint_CurrencyParsing_WithSpaces_TrimsSpaces() + { + // Arrange + const string currenciesString = " USD , EUR , GBP "; + + // Act + var currencyList = currenciesString + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(code => new Currency(code)) + .ToList(); + + // Assert + Assert.Equal(3, currencyList.Count); + Assert.Contains(currencyList, c => c.Code == "USD"); + Assert.Contains(currencyList, c => c.Code == "EUR"); + Assert.Contains(currencyList, c => c.Code == "GBP"); + } + + [Fact] + public void ExchangeRateEndpoint_CurrencyParsing_WithEmptyEntries_RemovesEmptyEntries() + { + // Arrange + const string currenciesString = "USD,,EUR,"; + + // Act + var currencyList = currenciesString + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(code => new Currency(code)) + .ToList(); + + // Assert + Assert.Equal(2, currencyList.Count); + Assert.Contains(currencyList, c => c.Code == "USD"); + Assert.Contains(currencyList, c => c.Code == "EUR"); + } + + [Fact] + public void ExchangeRateEndpoint_Validation_WithNullCurrencies_ShouldReturnBadRequest() + { + // Arrange + string? currencies = null; + + // Act & Assert - Test the validation logic from the endpoint + var result = string.IsNullOrWhiteSpace(currencies); + + Assert.True(result); + } + + [Fact] + public void ExchangeRateEndpoint_Validation_WithEmptyCurrencies_ShouldReturnBadRequest() + { + // Arrange + const string currencies = ""; + + // Act & Assert - Test the validation logic from the endpoint + var result = string.IsNullOrWhiteSpace(currencies); + + Assert.True(result); + } + + [Fact] + public void ExchangeRateEndpoint_Validation_WithWhitespaceCurrencies_ShouldReturnBadRequest() + { + // Arrange + const string currencies = " "; + + // Act & Assert - Test the validation logic from the endpoint + var result = string.IsNullOrWhiteSpace(currencies); + + Assert.True(result); + } + + [Fact] + public async Task ExchangeRateEndpoint_WithEmptyResponse_ReturnsEmptyList() + { + // Arrange + const string currencies = "USD"; + + _mockExchangeRateProviderService + .Setup(x => x.GetLatestAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync([]); + + // Act + var currencyList = currencies + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(code => new Currency(code)); + + var result = await _mockExchangeRateProviderService.Object + .GetLatestAsync(currencyList, CancellationToken.None); + + var response = result.Select(r => + new ExchangeRateDto(r.SourceCurrency.Code, r.TargetCurrency.Code, r.Value)); + + // Assert + Assert.NotNull(response); + Assert.Empty(response); + } + + [Fact] + public void ExchangeRateDto_Mapping_ShouldMapCorrectly() + { + // Arrange + var sourceCurrency = new Currency("USD"); + var targetCurrency = new Currency("CZK"); + var exchangeRate = new ExchangeRate(sourceCurrency, targetCurrency, 25.5m, DateOnly.FromDateTime(DateTime.Today)); + + // Act + var dto = new ExchangeRateDto( + exchangeRate.SourceCurrency.Code, + exchangeRate.TargetCurrency.Code, + exchangeRate.Value); + + // Assert + Assert.Equal("USD", dto.SourceCurrency); + Assert.Equal("CZK", dto.TargetCurrency); + Assert.Equal(25.5m, dto.Value); + } + + [Fact] + public async Task ExchangeRateEndpoint_ServiceThrowsException_PropagatesException() + { + // Arrange + const string currencies = "USD"; + var expectedException = new InvalidOperationException("Service error"); + + _mockExchangeRateProviderService + .Setup(x => x.GetLatestAsync(It.IsAny>(), It.IsAny())) + .ThrowsAsync(expectedException); + + // Act & Assert + var currencyList = currencies + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(code => new Currency(code)); + + var actualException = await Assert.ThrowsAsync( + () => _mockExchangeRateProviderService.Object.GetLatestAsync(currencyList, CancellationToken.None)); + + Assert.Same(expectedException, actualException); + } + + [Fact] + public async Task ExchangeRateEndpoint_WithCancellationToken_PassesToService() + { + // Arrange + const string currencies = "USD"; + var cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = cancellationTokenSource.Token; + + _mockExchangeRateProviderService + .Setup(x => x.GetLatestAsync(It.IsAny>(), cancellationToken)) + .ReturnsAsync([]); + + // Act + var currencyList = currencies + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(code => new Currency(code)); + + await _mockExchangeRateProviderService.Object + .GetLatestAsync(currencyList, cancellationToken); + + // Assert + _mockExchangeRateProviderService.Verify( + x => x.GetLatestAsync(It.IsAny>(), cancellationToken), + Times.Once); + } + + [Fact] + public void ExchangeRateEndpoints_MapExchangeRatesEndpoints_ReturnsRouteBuilder() + { + // Arrange + var builder = WebApplication.CreateBuilder(); + var app = builder.Build(); + + // Act + var result = app.MapExchangeRatesEndpoints(); + + // Assert + Assert.NotNull(result); + Assert.Same(app, result); + } + + [Fact] + public async Task ExchangeRateEndpoint_WithDuplicateCurrencies_ProcessesDuplicates() + { + // Arrange + const string currencies = "USD,EUR,USD,EUR"; + var expectedUniqueCurrencies = new[] { "USD", "EUR" }; + + var exchangeRates = expectedUniqueCurrencies.Select(code => + new ExchangeRate( + new Currency(code), + new Currency("CZK"), + 25.0m, + DateOnly.FromDateTime(DateTime.Today))) + .ToList(); + + _mockExchangeRateProviderService + .Setup(x => x.GetLatestAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(exchangeRates); + + // Act + var currencyList = currencies + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(code => new Currency(code)); + + var result = await _mockExchangeRateProviderService.Object + .GetLatestAsync(currencyList, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Count); + } + + [Fact] + public async Task ExchangeRateEndpoint_WithValidResponse_MapsToExchangeRateDtoCorrectly() + { + // Arrange + var usd = new Currency("USD"); + var eur = new Currency("EUR"); + var czk = new Currency("CZK"); + + var exchangeRates = new List + { + new(usd, czk, 25.5m, DateOnly.FromDateTime(DateTime.Today)), + new(eur, czk, 24.8m, DateOnly.FromDateTime(DateTime.Today)) + }; + + _mockExchangeRateProviderService + .Setup(x => x.GetLatestAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync(exchangeRates); + + const string currencies = "USD,EUR"; + + // Act + var currencyList = currencies + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(code => new Currency(code)); + + var result = await _mockExchangeRateProviderService.Object + .GetLatestAsync(currencyList, CancellationToken.None); + + var dtoResponse = result.Select(r => + new ExchangeRateDto(r.SourceCurrency.Code, r.TargetCurrency.Code, r.Value)); + + // Assert + Assert.NotNull(dtoResponse); + Assert.Equal(2, dtoResponse.Count()); + + var usdDto = dtoResponse.First(dto => dto.SourceCurrency == "USD"); + Assert.Equal("USD", usdDto.SourceCurrency); + Assert.Equal("CZK", usdDto.TargetCurrency); + Assert.Equal(25.5m, usdDto.Value); + + var eurDto = dtoResponse.First(dto => dto.SourceCurrency == "EUR"); + Assert.Equal("EUR", eurDto.SourceCurrency); + Assert.Equal("CZK", eurDto.TargetCurrency); + Assert.Equal(24.8m, eurDto.Value); + } + + [Fact] + public async Task ExchangeRateEndpoint_VerifyServiceCalledWithCorrectParameters() + { + // Arrange + const string currencies = "USD,EUR"; + + _mockExchangeRateProviderService + .Setup(x => x.GetLatestAsync(It.IsAny>(), It.IsAny())) + .ReturnsAsync([]); + + // Act + var currencyList = currencies + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(code => new Currency(code)); + + await _mockExchangeRateProviderService.Object + .GetLatestAsync(currencyList, CancellationToken.None); + + // Assert + _mockExchangeRateProviderService.Verify( + x => x.GetLatestAsync( + It.Is>(currencies => + currencies.Count() == 2 && + currencies.Any(c => c.Code == "USD") && + currencies.Any(c => c.Code == "EUR")), + It.IsAny()), + Times.Once); + } + + [Theory] + [InlineData("USD")] + [InlineData("USD,EUR")] + [InlineData("USD,EUR,GBP")] + [InlineData(" USD ")] + [InlineData(" USD , EUR ")] + public void ExchangeRateEndpoint_CurrencyParsing_WithVariousInputs_ParsesCorrectly(string input) + { + // Act + var currencyList = input + .Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries) + .Select(code => new Currency(code)) + .ToList(); + + // Assert + Assert.NotEmpty(currencyList); + Assert.All(currencyList, currency => Assert.NotNull(currency.Code)); + Assert.All(currencyList, currency => Assert.NotEmpty(currency.Code.Trim())); + } + + [Theory] + [InlineData(null)] + [InlineData("")] + [InlineData(" ")] + [InlineData("\t")] + [InlineData("\n")] + public void ExchangeRateEndpoint_Validation_WithInvalidCurrencies_ShouldReturnBadRequest(string? currencies) + { + // Act & Assert + var result = string.IsNullOrWhiteSpace(currencies); + Assert.True(result); + } + + [Fact] + public void ExchangeRateEndpoint_BadRequestMessage_ShouldMatchExpected() + { + // Arrange + const string expectedMessage = "Query string parameter 'currencies' is required. Example: ?currencies=USD,EUR"; + + // Act & Assert + Assert.Equal("Query string parameter 'currencies' is required. Example: ?currencies=USD,EUR", expectedMessage); + } +} diff --git a/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Api.Tests/ExchangeRateProvider.Api.Tests.csproj b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Api.Tests/ExchangeRateProvider.Api.Tests.csproj new file mode 100644 index 0000000000..5dcd0b4cbb --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Api.Tests/ExchangeRateProvider.Api.Tests.csproj @@ -0,0 +1,29 @@ + + + + net10.0 + enable + enable + false + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Application.UnitTests/ExchangeRateProvider.Application.UnitTests.csproj b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Application.UnitTests/ExchangeRateProvider.Application.UnitTests.csproj new file mode 100644 index 0000000000..0e1d3ee0e5 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Application.UnitTests/ExchangeRateProvider.Application.UnitTests.csproj @@ -0,0 +1,24 @@ + + + + false + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Application.UnitTests/ExchangeRateProviderServiceTests.cs b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Application.UnitTests/ExchangeRateProviderServiceTests.cs new file mode 100644 index 0000000000..a09d842db6 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Application.UnitTests/ExchangeRateProviderServiceTests.cs @@ -0,0 +1,204 @@ +using ExchangeRateProvider.Application.Interfaces; +using ExchangeRateProvider.Application.Services; +using ExchangeRateProvider.Domain.Entities; +using Moq; + +namespace ExchangeRateProvider.Application.UnitTests; + +public class ExchangeRateProviderServiceTests +{ + private readonly Mock _mockExchangeRateProvider; + private readonly ExchangeRateProviderService _service; + + public ExchangeRateProviderServiceTests() + { + _mockExchangeRateProvider = new Mock(); + _service = new ExchangeRateProviderService(_mockExchangeRateProvider.Object); + } + + [Fact] + public async Task GetLatestAsync_WithValidCurrencies_ReturnsFilteredExchangeRates() + { + // Arrange + var usd = new Currency("USD"); + var eur = new Currency("EUR"); + var gbp = new Currency("GBP"); + var czk = new Currency("CZK"); + + var allExchangeRates = new List + { + new(usd, czk, 25.5m, DateOnly.FromDateTime(DateTime.Today)), + new(eur, czk, 24.8m, DateOnly.FromDateTime(DateTime.Today)), + new(gbp, czk, 31.2m, DateOnly.FromDateTime(DateTime.Today)), + }; + + var requestedCurrencies = new[] { usd, eur }; + + _mockExchangeRateProvider + .Setup(x => x.GetLatestAsync(It.IsAny())) + .ReturnsAsync(allExchangeRates); + + // Act + var result = await _service.GetLatestAsync(requestedCurrencies); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Count); + Assert.All(result, rate => Assert.Contains(rate.SourceCurrency, requestedCurrencies)); + Assert.Contains(result, rate => rate.SourceCurrency == usd); + Assert.Contains(result, rate => rate.SourceCurrency == eur); + } + + [Fact] + public async Task GetLatestAsync_WithEmptyCurrencies_ReturnsEmptyList() + { + // Arrange + var allExchangeRates = new List + { + new(new Currency("USD"), new Currency("CZK"), 25.5m, DateOnly.FromDateTime(DateTime.Today)), + new(new Currency("EUR"), new Currency("CZK"), 24.8m, DateOnly.FromDateTime(DateTime.Today)) + }; + + var emptyCurrencies = Array.Empty(); + + _mockExchangeRateProvider + .Setup(x => x.GetLatestAsync(It.IsAny())) + .ReturnsAsync(allExchangeRates); + + // Act + var result = await _service.GetLatestAsync(emptyCurrencies); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetLatestAsync_WithNonMatchingCurrencies_ReturnsEmptyList() + { + // Arrange + var allExchangeRates = new List + { + new(new Currency("USD"), new Currency("CZK"), 25.5m, DateOnly.FromDateTime(DateTime.Today)), + new(new Currency("EUR"), new Currency("CZK"), 24.8m, DateOnly.FromDateTime(DateTime.Today)) + }; + + var requestedCurrencies = new[] { new Currency("GBP"), new Currency("JPY") }; + + _mockExchangeRateProvider + .Setup(x => x.GetLatestAsync(It.IsAny())) + .ReturnsAsync(allExchangeRates); + + // Act + var result = await _service.GetLatestAsync(requestedCurrencies); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetLatestAsync_WithDuplicateCurrencies_ReturnsUniqueResults() + { + // Arrange + var usd = new Currency("USD"); + var eur = new Currency("EUR"); + + var allExchangeRates = new List + { + new(usd, new Currency("CZK"), 25.5m, DateOnly.FromDateTime(DateTime.Today)), + new(eur, new Currency("CZK"), 24.8m, DateOnly.FromDateTime(DateTime.Today)) + }; + + var requestedCurrencies = new[] { usd, usd, eur }; // USD duplicated + + _mockExchangeRateProvider + .Setup(x => x.GetLatestAsync(It.IsAny())) + .ReturnsAsync(allExchangeRates); + + // Act + var result = await _service.GetLatestAsync(requestedCurrencies); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Count); + Assert.Single(result, rate => rate.SourceCurrency == usd); + Assert.Single(result, rate => rate.SourceCurrency == eur); + } + + [Fact] + public async Task GetLatestAsync_WithEmptyProviderResponse_ReturnsEmptyList() + { + // Arrange + var requestedCurrencies = new[] { new Currency("USD"), new Currency("EUR") }; + + _mockExchangeRateProvider + .Setup(x => x.GetLatestAsync(It.IsAny())) + .ReturnsAsync([]); + + // Act + var result = await _service.GetLatestAsync(requestedCurrencies); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetLatestAsync_WithCancellationToken_PassesToProvider() + { + // Arrange + var cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = cancellationTokenSource.Token; + var requestedCurrencies = new[] { new Currency("USD") }; + + _mockExchangeRateProvider + .Setup(x => x.GetLatestAsync(cancellationToken)) + .ReturnsAsync([]); + + // Act + await _service.GetLatestAsync(requestedCurrencies, cancellationToken); + + // Assert + _mockExchangeRateProvider.Verify( + x => x.GetLatestAsync(cancellationToken), + Times.Once); + } + + [Fact] + public async Task GetLatestAsync_WhenProviderThrows_PropagatesException() + { + // Arrange + var requestedCurrencies = new[] { new Currency("USD") }; + var expectedException = new InvalidOperationException("Provider error"); + + _mockExchangeRateProvider + .Setup(x => x.GetLatestAsync(It.IsAny())) + .ThrowsAsync(expectedException); + + // Act & Assert + var actualException = await Assert.ThrowsAsync( + () => _service.GetLatestAsync(requestedCurrencies)); + + Assert.Same(expectedException, actualException); + } + + [Fact] + public async Task GetLatestAsync_AlwaysCallsProviderOnce() + { + // Arrange + var requestedCurrencies = new[] { new Currency("USD"), new Currency("EUR") }; + + _mockExchangeRateProvider + .Setup(x => x.GetLatestAsync(It.IsAny())) + .ReturnsAsync([]); + + // Act + await _service.GetLatestAsync(requestedCurrencies); + + // Assert + _mockExchangeRateProvider.Verify( + x => x.GetLatestAsync(It.IsAny()), + Times.Once); + } +} diff --git a/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Application.UnitTests/ServiceCollectionExtensionsTests.cs b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Application.UnitTests/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000000..d4a108fe98 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Application.UnitTests/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,148 @@ +using ExchangeRateProvider.Application.Interfaces; +using ExchangeRateProvider.Application.Services; +using Microsoft.Extensions.DependencyInjection; +using Moq; + +namespace ExchangeRateProvider.Application.UnitTests; + +public class ServiceCollectionExtensionsTests +{ + [Fact] + public void AddApplicationServices_RegistersExchangeRateProviderService_AsScoped() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddApplicationServices(); + + // Assert + var serviceDescriptor = services.FirstOrDefault(s => s.ServiceType == typeof(IExchangeRateProviderService)); + + Assert.NotNull(serviceDescriptor); + Assert.Equal(typeof(ExchangeRateProviderService), serviceDescriptor.ImplementationType); + Assert.Equal(ServiceLifetime.Scoped, serviceDescriptor.Lifetime); + } + + [Fact] + public void AddApplicationServices_ReturnsServiceCollection_ForMethodChaining() + { + // Arrange + var services = new ServiceCollection(); + + // Act + var result = services.AddApplicationServices(); + + // Assert + Assert.Same(services, result); + } + + [Fact] + public void AddApplicationServices_CanResolveExchangeRateProviderService_WhenDependenciesProvided() + { + // Arrange + var services = new ServiceCollection(); + var mockProvider = new Mock(); + + services.AddSingleton(mockProvider.Object); + services.AddApplicationServices(); + + using var serviceProvider = services.BuildServiceProvider(); + + // Act + var service = serviceProvider.GetService(); + + // Assert + Assert.NotNull(service); + Assert.IsType(service); + } + + [Fact] + public void AddApplicationServices_RegistersService_OnlyOnce() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddApplicationServices(); + + // Assert + var serviceDescriptors = services.Where(s => s.ServiceType == typeof(IExchangeRateProviderService)); + Assert.Single(serviceDescriptors); + } + + [Fact] + public void AddApplicationServices_CalledMultipleTimes_RegistersServiceMultipleTimes() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddApplicationServices(); + services.AddApplicationServices(); + + // Assert + var serviceDescriptors = services.Where(s => s.ServiceType == typeof(IExchangeRateProviderService)); + Assert.Equal(2, serviceDescriptors.Count()); + Assert.All(serviceDescriptors, descriptor => + { + Assert.Equal(typeof(ExchangeRateProviderService), descriptor.ImplementationType); + Assert.Equal(ServiceLifetime.Scoped, descriptor.Lifetime); + }); + } + + [Fact] + public void AddApplicationServices_WithExistingServices_PreservesExistingRegistrations() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton("test-service"); + + // Act + services.AddApplicationServices(); + + // Assert + Assert.Contains(services, s => s.ServiceType == typeof(string)); + Assert.Contains(services, s => s.ServiceType == typeof(IExchangeRateProviderService)); + Assert.Equal(2, services.Count); + } + + [Fact] + public void AddApplicationServices_CreatesNewInstancePerScope() + { + // Arrange + var services = new ServiceCollection(); + var mockProvider = new Mock(); + + services.AddSingleton(mockProvider.Object); + services.AddApplicationServices(); + + using var serviceProvider = services.BuildServiceProvider(); + + // Act + IExchangeRateProviderService service1; + IExchangeRateProviderService service2; + IExchangeRateProviderService service3; + IExchangeRateProviderService service4; + + using (var scope1 = serviceProvider.CreateScope()) + { + service1 = scope1.ServiceProvider.GetRequiredService(); + service2 = scope1.ServiceProvider.GetRequiredService(); + } + + using (var scope2 = serviceProvider.CreateScope()) + { + service3 = scope2.ServiceProvider.GetRequiredService(); + service4 = scope2.ServiceProvider.GetRequiredService(); + } + + // Assert + // Same instance within the same scope + Assert.Same(service1, service2); + Assert.Same(service3, service4); + + // Different instances across different scopes + Assert.NotSame(service1, service3); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Domain.Tests/Entities/CurrencyTests.cs b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Domain.Tests/Entities/CurrencyTests.cs new file mode 100644 index 0000000000..9b56b28a96 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Domain.Tests/Entities/CurrencyTests.cs @@ -0,0 +1,39 @@ +using ExchangeRateProvider.Domain.Entities; + +namespace ExchangeRateProvider.Domain.Tests.Entities; + +public class CurrencyTests +{ + [Theory] + [InlineData("USD")] + [InlineData("EUR")] + [InlineData("CZK")] + [InlineData("GBP")] + [InlineData("JPY")] + public void Currency_Constructor_WithValidCodes_SetsCodeCorrectly(string code) + { + // Act + var currency = new Currency(code); + + // Assert + Assert.Equal(code, currency.Code); + } + + [Theory] + [InlineData("USD")] + [InlineData("EUR")] + [InlineData("CZK")] + [InlineData("GBP")] + [InlineData("JPY")] + public void Currency_ToString_ReturnsCode(string code) + { + // Arrange + var currency = new Currency(code); + + // Act + var result = currency.ToString(); + + // Assert + Assert.Equal(code, result); + } +} diff --git a/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Domain.Tests/ExchangeRateProvider.Domain.Tests.csproj b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Domain.Tests/ExchangeRateProvider.Domain.Tests.csproj new file mode 100644 index 0000000000..1cca89d512 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Domain.Tests/ExchangeRateProvider.Domain.Tests.csproj @@ -0,0 +1,23 @@ + + + + false + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Infrastructure.Tests/ExchangeRateProvider.Infrastructure.Tests.csproj b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Infrastructure.Tests/ExchangeRateProvider.Infrastructure.Tests.csproj new file mode 100644 index 0000000000..2e22da34ac --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Infrastructure.Tests/ExchangeRateProvider.Infrastructure.Tests.csproj @@ -0,0 +1,26 @@ + + + + false + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Infrastructure.Tests/ExchangeRateProviders/Cnb/CnbCacheDateTimeProviderTests.cs b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Infrastructure.Tests/ExchangeRateProviders/Cnb/CnbCacheDateTimeProviderTests.cs new file mode 100644 index 0000000000..038bb3bfba --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Infrastructure.Tests/ExchangeRateProviders/Cnb/CnbCacheDateTimeProviderTests.cs @@ -0,0 +1,235 @@ +using ExchangeRateProvider.Infrastructure.ExchangeRateProviders.Cnb; +using Microsoft.Extensions.Time.Testing; + +namespace ExchangeRateProvider.Infrastructure.Tests.ExchangeRateProviders.Cnb; + +public class CnbCacheDateTimeProviderTests +{ + [Fact] + public void GetNextFixingExpirationUtc_BeforeFixingTimeOnBusinessDay_ReturnsFixingTimeToday() + { + // Arrange + // Monday, January 15, 2024, at 11:00 UTC (12:00 Prague) + var fakeTimeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 11, 0, 0, TimeSpan.Zero)); + var provider = new CnbCacheDateTimeProvider(fakeTimeProvider); + var customBuffer = TimeSpan.FromMinutes(10); + + // Act + var result = provider.GetNextFixingExpirationUtc(customBuffer); + + // Assert + // Should return today's fixing time (14:30 Prague) + 10 minutes buffer = 13:40 UTC + var expected = new DateTimeOffset(2024, 1, 15, 13, 40, 0, TimeSpan.Zero); + Assert.Equal(expected, result); + } + + [Fact] + public void GetNextFixingExpirationUtc_AfterFixingTimeOnBusinessDay_ReturnsNextBusinessDayFixing() + { + // Arrange + // Monday, January 15, 2024, at 14:00 UTC (15:00 Prague) + var fakeTimeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 14, 0, 0, TimeSpan.Zero)); + var provider = new CnbCacheDateTimeProvider(fakeTimeProvider); + var customBuffer = TimeSpan.FromMinutes(15); + + // Act + var result = provider.GetNextFixingExpirationUtc(customBuffer); + + // Assert + // Should return next business day (Tuesday, 14:45 Prague) fixing time + 15 minutes buffer + var expected = new DateTimeOffset(2024, 1, 16, 13, 45, 0, TimeSpan.Zero); + Assert.Equal(expected, result); + } + + [Fact] + public void GetNextFixingExpirationUtc_OnSaturday_ReturnsNextMondayFixing() + { + // Arrange + // Saturday, January 13, 2024 at 12:00 UTC (13:00 Prague) + var fakeTimeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 13, 12, 0, 0, TimeSpan.Zero)); + var provider = new CnbCacheDateTimeProvider(fakeTimeProvider); + var customBuffer = TimeSpan.FromMinutes(5); + + // Act + var result = provider.GetNextFixingExpirationUtc(customBuffer); + + // Assert + // Should return next Monday (January 15, 14:35 Prague) fixing time + 5 minutes buffer + var expected = new DateTimeOffset(2024, 1, 15, 13, 35, 0, TimeSpan.Zero); + Assert.Equal(expected, result); + } + + [Fact] + public void GetNextFixingExpirationUtc_OnSunday_ReturnsNextMondayFixing() + { + // Arrange + // Sunday, January 14, 2024 at 10:00 UTC (11:00 Prague) + var fakeTimeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 14, 10, 0, 0, TimeSpan.Zero)); + var provider = new CnbCacheDateTimeProvider(fakeTimeProvider); + + // Act + var result = provider.GetNextFixingExpirationUtc(); + + // Assert + // Should return next Monday (January 15, 14:35 Prague) fixing time + default 5 minutes buffer + var expected = new DateTimeOffset(2024, 1, 15, 13, 35, 0, TimeSpan.Zero); + Assert.Equal(expected, result); + } + + [Fact] + public void GetNextFixingExpirationUtc_OnFridayAfterFixing_ReturnsNextMondayFixing() + { + // Arrange + // Friday, January 19, 2024, at 15:00 UTC (16:00 Prague) + var fakeTimeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 19, 15, 0, 0, TimeSpan.Zero)); + var provider = new CnbCacheDateTimeProvider(fakeTimeProvider); + var customBuffer = TimeSpan.FromMinutes(3); + + // Act + var result = provider.GetNextFixingExpirationUtc(customBuffer); + + // Assert + // Should return next Monday (January 22, 14:33 Prague) fixing time + 3 minutes buffer + var expected = new DateTimeOffset(2024, 1, 22, 13, 33, 0, TimeSpan.Zero); + Assert.Equal(expected, result); + } + + [Theory] + [InlineData("2024-01-15", 13, 29, "2024-01-15", 13, 35)] // Monday before fixing -> today + buffer + [InlineData("2024-01-15", 13, 31, "2024-01-16", 13, 35)] // Monday after fixing -> tomorrow + buffer + [InlineData("2024-01-16", 13, 29, "2024-01-16", 13, 35)] // Tuesday before fixing -> today + buffer + [InlineData("2024-01-17", 13, 31, "2024-01-18", 13, 35)] // Wednesday after fixing -> tomorrow + buffer + [InlineData("2024-01-18", 13, 29, "2024-01-18", 13, 35)] // Thursday before fixing -> today + buffer + [InlineData("2024-01-19", 13, 31, "2024-01-22", 13, 35)] // Friday after fixing -> next Monday + buffer + public void GetNextFixingExpirationUtc_VariousBusinessDayScenarios_ReturnsExpectedResult( + string currentDate, int currentHour, int currentMinute, + string expectedDate, int expectedHour, int expectedMinute) + { + // Arrange + var current = DateTime.Parse(currentDate).AddHours(currentHour).AddMinutes(currentMinute); + var fakeTimeProvider = new FakeTimeProvider(new DateTimeOffset(current, TimeSpan.Zero)); + var provider = new CnbCacheDateTimeProvider(fakeTimeProvider); + + var expected = DateTime.Parse(expectedDate).AddHours(expectedHour).AddMinutes(expectedMinute); + var expectedResult = new DateTimeOffset(expected, TimeSpan.Zero); + + // Act + var result = provider.GetNextFixingExpirationUtc(); + + // Assert + Assert.Equal(expectedResult, result); + } + + [Fact] + public void GetNextFixingExpirationUtc_WithZeroBuffer_ReturnsExactFixingTime() + { + // Arrange + // Monday, January 15, 2024, at 10:00 UTC (11:00 Prague) + var fakeTimeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 10, 0, 0, TimeSpan.Zero)); + var provider = new CnbCacheDateTimeProvider(fakeTimeProvider); + var zeroBuffer = TimeSpan.Zero; + + // Act + var result = provider.GetNextFixingExpirationUtc(zeroBuffer); + + // Assert + // Should return exact fixing time without buffer (14:30 Prague) + var expected = new DateTimeOffset(2024, 1, 15, 13, 30, 0, TimeSpan.Zero); + Assert.Equal(expected, result); + } + + [Fact] + public void GetNextFixingExpirationUtc_WithNegativeBuffer_ReturnsTimeBeforeFixing() + { + // Arrange + // Monday, January 15, 2024, at 10:00 UTC (11:00 Prague) + var fakeTimeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 10, 0, 0, TimeSpan.Zero)); + var provider = new CnbCacheDateTimeProvider(fakeTimeProvider); + var negativeBuffer = TimeSpan.FromMinutes(-10); + + // Act + var result = provider.GetNextFixingExpirationUtc(negativeBuffer); + + // Assert + // Should return fixing time minus 10 minutes (14:20 Prague) + var expected = new DateTimeOffset(2024, 1, 15, 13, 20, 0, TimeSpan.Zero); + Assert.Equal(expected, result); + } + + [Fact] + public void GetNextFixingExpirationUtc_WithDefaultBuffer_Uses5MinuteBuffer() + { + // Arrange + // Monday, January 15, 2024, at 10:00 UTC (11:00 Prague) + var fakeTimeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 10, 0, 0, TimeSpan.Zero)); + var provider = new CnbCacheDateTimeProvider(fakeTimeProvider); + + // Act + var result = provider.GetNextFixingExpirationUtc(); + + // Assert + // Should return fixing time + default 5 minutes buffer (14:35 Prague) + var expected = new DateTimeOffset(2024, 1, 15, 13, 35, 0, TimeSpan.Zero); + Assert.Equal(expected, result); + } + + [Fact] + public void GetNextFixingExpirationUtc_DuringDaylightSavingTime_HandlesTimezoneCorrectly() + { + // Monday, July 15, 2024, at 10:00 UTC (12:00 Prague, summer) + var fakeTimeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 7, 15, 10, 0, 0, TimeSpan.Zero)); + var provider = new CnbCacheDateTimeProvider(fakeTimeProvider); + + // Act + var result = provider.GetNextFixingExpirationUtc(); + + // Assert + // Should return fixing time + default 5 minutes buffer (14:35 Prague) + var expected = new DateTimeOffset(2024, 7, 15, 12, 35, 0, TimeSpan.Zero); + Assert.Equal(expected, result); + } + + [Fact] + public void GetNextFixingExpirationUtc_AlwaysReturnsUtcOffset() + { + // Arrange + var fakeTimeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 10, 0, 0, TimeSpan.Zero)); + var provider = new CnbCacheDateTimeProvider(fakeTimeProvider); + + // Act + var result = provider.GetNextFixingExpirationUtc(); + + // Assert + Assert.Equal(TimeSpan.Zero, result.Offset); + } + + [Fact] + public void GetNextFixingExpirationUtc_WithSystemTimeProvider_UsesCurrentTime() + { + // Arrange - using system time provider + var provider = new CnbCacheDateTimeProvider(TimeProvider.System); + var beforeCall = DateTimeOffset.UtcNow; + + // Act + var result = provider.GetNextFixingExpirationUtc(); + + // Assert + Assert.True(result > beforeCall, "Expiration should be in the future"); + } + + [Fact] + public void GetNextFixingExpirationUtc_ConsistentResultsWithSameTimeProvider() + { + // Arrange - Fixed time + var fakeTimeProvider = new FakeTimeProvider(new DateTimeOffset(2024, 1, 15, 10, 0, 0, TimeSpan.Zero)); + var provider = new CnbCacheDateTimeProvider(fakeTimeProvider); + var buffer = TimeSpan.FromMinutes(7); + + // Act + var result1 = provider.GetNextFixingExpirationUtc(buffer); + var result2 = provider.GetNextFixingExpirationUtc(buffer); + + // Assert + Assert.Equal(result1, result2); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Infrastructure.Tests/ExchangeRateProviders/Cnb/CnbCachedExchangeRateProviderDecoratorTests.cs b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Infrastructure.Tests/ExchangeRateProviders/Cnb/CnbCachedExchangeRateProviderDecoratorTests.cs new file mode 100644 index 0000000000..76192c0be3 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Infrastructure.Tests/ExchangeRateProviders/Cnb/CnbCachedExchangeRateProviderDecoratorTests.cs @@ -0,0 +1,468 @@ +using ExchangeRateProvider.Application.Interfaces; +using ExchangeRateProvider.Domain.Entities; +using ExchangeRateProvider.Infrastructure.ExchangeRateProviders; +using ExchangeRateProvider.Infrastructure.ExchangeRateProviders.Cnb; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Logging; +using Moq; +using System.Collections.ObjectModel; + +namespace ExchangeRateProvider.Infrastructure.Tests.ExchangeRateProviders.Cnb; + +public class CnbCachedExchangeRateProviderDecoratorTests : IDisposable +{ + private readonly Mock _mockInnerProvider; + private readonly Mock _mockDateTimeProvider; + private readonly MemoryCache _memoryCache; + private readonly CnbCachedExchangeRateProviderDecorator _decorator; + + public CnbCachedExchangeRateProviderDecoratorTests() + { + _mockInnerProvider = new Mock(); + _mockDateTimeProvider = new Mock(); + _memoryCache = new MemoryCache(new MemoryCacheOptions()); + var mockLogger = new Mock>(); + _decorator = new CnbCachedExchangeRateProviderDecorator(_mockInnerProvider.Object, + _memoryCache, _mockDateTimeProvider.Object, mockLogger.Object); + + // Setup default behavior for date time provider, Default: 1 hour from now + _mockDateTimeProvider + .Setup(x => x.GetNextFixingExpirationUtc(It.IsAny())) + .Returns(DateTimeOffset.UtcNow.AddHours(1)); + } + + [Fact] + public async Task GetLatestAsync_WithNoCachedData_CallsInnerProviderAndCachesResult() + { + // Arrange + var exchangeRates = CreateTestExchangeRates(DateOnly.FromDateTime(DateTime.Today)); + + _mockInnerProvider + .Setup(x => x.GetLatestAsync(It.IsAny())) + .ReturnsAsync(exchangeRates); + + // Act + var result = await _decorator.GetLatestAsync(); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Count); + Assert.Equal(exchangeRates[0].SourceCurrency, result[0].SourceCurrency); + Assert.Equal(exchangeRates[1].SourceCurrency, result[1].SourceCurrency); + + _mockInnerProvider.Verify(x => x.GetLatestAsync(It.IsAny()), Times.Once); + _mockDateTimeProvider.Verify(x => x.GetNextFixingExpirationUtc(It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetLatestAsync_WithCachedData_DoesNotCallInnerProvider() + { + // Arrange + var exchangeRates = CreateTestExchangeRates(DateOnly.FromDateTime(DateTime.Today)); + const string validForKey = "ExchangeRates:LatestValidFor"; + var ratesKey = "ExchangeRates:ValidFor:" + DateTime.Today.ToString("yyyy-MM-dd"); + + _memoryCache.Set(validForKey, DateTime.Today.ToString("yyyy-MM-dd")); + _memoryCache.Set(ratesKey, exchangeRates); + + // Act + var result = await _decorator.GetLatestAsync(); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Count); + Assert.Equal(exchangeRates[0].SourceCurrency, result[0].SourceCurrency); + + _mockInnerProvider.Verify(x => x.GetLatestAsync(It.IsAny()), Times.Never); + _mockDateTimeProvider.Verify(x => x.GetNextFixingExpirationUtc(It.IsAny()), Times.Never); + } + + [Fact] + public async Task GetLatestAsync_WithEmptyInnerProviderResponse_ReturnsEmptyList() + { + // Arrange + var emptyRates = new List().AsReadOnly(); + + _mockInnerProvider + .Setup(x => x.GetLatestAsync(It.IsAny())) + .ReturnsAsync(emptyRates); + + // Act + var result = await _decorator.GetLatestAsync(); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + + _mockInnerProvider.Verify(x => x.GetLatestAsync(It.IsAny()), Times.Once); + _mockDateTimeProvider.Verify(x => x.GetNextFixingExpirationUtc(It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetLatestAsync_CallsDateTimeProviderWithCorrectBuffer() + { + // Arrange + var exchangeRates = CreateTestExchangeRates(DateOnly.FromDateTime(DateTime.Today)); + + _mockInnerProvider + .Setup(x => x.GetLatestAsync(It.IsAny())) + .ReturnsAsync(exchangeRates); + + // Act + var result = await _decorator.GetLatestAsync(); + + // Assert + Assert.NotNull(result); + _mockDateTimeProvider.Verify(x => x.GetNextFixingExpirationUtc(TimeSpan.FromMinutes(5)), Times.Once); + } + + [Fact] + public async Task GetLatestAsync_UsesExpirationFromDateTimeProvider() + { + // Arrange + var exchangeRates = CreateTestExchangeRates(DateOnly.FromDateTime(DateTime.Today)); + var customExpiration = DateTimeOffset.UtcNow.AddHours(2); + + _mockInnerProvider + .Setup(x => x.GetLatestAsync(It.IsAny())) + .ReturnsAsync(exchangeRates); + + _mockDateTimeProvider + .Setup(x => x.GetNextFixingExpirationUtc(It.IsAny())) + .Returns(customExpiration); + + // Act + var result = await _decorator.GetLatestAsync(); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Count); + + _mockDateTimeProvider.Verify(x => x.GetNextFixingExpirationUtc(TimeSpan.FromMinutes(5)), Times.Once); + } + + [Fact] + public async Task GetLatestAsync_WithValidForKeyButNoRatesInCache_CallsInnerProvider() + { + // Arrange + var exchangeRates = CreateTestExchangeRates(DateOnly.FromDateTime(DateTime.Today)); + const string validForKey = "ExchangeRates:LatestValidFor"; + + _memoryCache.Set(validForKey, DateTime.Today.ToString("yyyy-MM-dd")); + // Rates key is not set in cache + + _mockInnerProvider + .Setup(x => x.GetLatestAsync(It.IsAny())) + .ReturnsAsync(exchangeRates); + + // Act + var result = await _decorator.GetLatestAsync(); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Count); + + _mockInnerProvider.Verify(x => x.GetLatestAsync(It.IsAny()), Times.Once); + _mockDateTimeProvider.Verify(x => x.GetNextFixingExpirationUtc(It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetLatestAsync_WithEmptyValidForString_CallsInnerProvider() + { + // Arrange + var exchangeRates = CreateTestExchangeRates(DateOnly.FromDateTime(DateTime.Today)); + const string validForKey = "ExchangeRates:LatestValidFor"; + + _memoryCache.Set(validForKey, ""); // Empty string + + _mockInnerProvider + .Setup(x => x.GetLatestAsync(It.IsAny())) + .ReturnsAsync(exchangeRates); + + // Act + var result = await _decorator.GetLatestAsync(); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Count); + + _mockInnerProvider.Verify(x => x.GetLatestAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetLatestAsync_WithWhitespaceValidForString_CallsInnerProvider() + { + // Arrange + var exchangeRates = CreateTestExchangeRates(DateOnly.FromDateTime(DateTime.Today)); + const string validForKey = "ExchangeRates:LatestValidFor"; + + _memoryCache.Set(validForKey, " "); // Whitespace string + + _mockInnerProvider + .Setup(x => x.GetLatestAsync(It.IsAny())) + .ReturnsAsync(exchangeRates); + + // Act + var result = await _decorator.GetLatestAsync(); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Count); + + _mockInnerProvider.Verify(x => x.GetLatestAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetLatestAsync_WithEmptyCachedRates_CallsInnerProvider() + { + // Arrange + var exchangeRates = CreateTestExchangeRates(DateOnly.FromDateTime(DateTime.Today)); + const string validForKey = "ExchangeRates:LatestValidFor"; + var ratesKey = "ExchangeRates:ValidFor:" + DateTime.Today.ToString("yyyy-MM-dd"); + + _memoryCache.Set(validForKey, DateTime.Today.ToString("yyyy-MM-dd")); + _memoryCache.Set(ratesKey, new List().AsReadOnly()); // Empty cached rates + + _mockInnerProvider + .Setup(x => x.GetLatestAsync(It.IsAny())) + .ReturnsAsync(exchangeRates); + + // Act + var result = await _decorator.GetLatestAsync(); + + // Assert + Assert.NotNull(result); + Assert.Equal(2, result.Count); + + _mockInnerProvider.Verify(x => x.GetLatestAsync(It.IsAny()), Times.Once); + } + + [Fact] + public async Task GetLatestAsync_CachesDataWithCorrectKeys() + { + // Arrange + var testDate = new DateOnly(2024, 1, 15); + var exchangeRates = CreateTestExchangeRates(testDate); + + _mockInnerProvider + .Setup(x => x.GetLatestAsync(It.IsAny())) + .ReturnsAsync(exchangeRates); + + // Act + _ = await _decorator.GetLatestAsync(); + + // Assert + const string validForKey = "ExchangeRates:LatestValidFor"; + const string ratesKey = "ExchangeRates:ValidFor:2024-01-15"; + + Assert.True(_memoryCache.TryGetValue(validForKey, out var cachedValidFor)); + Assert.Equal("2024-01-15", cachedValidFor); + + Assert.True(_memoryCache.TryGetValue(ratesKey, out var cachedRates)); + var cachedExchangeRates = cachedRates as IReadOnlyList; + Assert.NotNull(cachedExchangeRates); + Assert.Equal(2, cachedExchangeRates.Count); + } + + [Fact] + public async Task GetLatestAsync_WithCancellationToken_PassesToInnerProvider() + { + // Arrange + var cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = cancellationTokenSource.Token; + var exchangeRates = CreateTestExchangeRates(DateOnly.FromDateTime(DateTime.Today)); + + _mockInnerProvider + .Setup(x => x.GetLatestAsync(cancellationToken)) + .ReturnsAsync(exchangeRates); + + // Act + await _decorator.GetLatestAsync(cancellationToken); + + // Assert + _mockInnerProvider.Verify(x => x.GetLatestAsync(cancellationToken), Times.Once); + } + + [Fact] + public async Task GetLatestAsync_WhenInnerProviderThrows_PropagatesException() + { + // Arrange + var expectedException = new InvalidOperationException("Inner provider error"); + + _mockInnerProvider + .Setup(x => x.GetLatestAsync(It.IsAny())) + .ThrowsAsync(expectedException); + + // Act & Assert + var actualException = await Assert.ThrowsAsync( + () => _decorator.GetLatestAsync()); + + Assert.Same(expectedException, actualException); + } + + [Fact] + public async Task GetLatestAsync_WhenDateTimeProviderThrows_PropagatesException() + { + // Arrange + var expectedException = new InvalidOperationException("DateTime provider error"); + + _mockInnerProvider + .Setup(x => x.GetLatestAsync(It.IsAny())) + .ReturnsAsync(CreateTestExchangeRates(DateOnly.FromDateTime(DateTime.Today))); + + _mockDateTimeProvider + .Setup(x => x.GetNextFixingExpirationUtc(It.IsAny())) + .Throws(expectedException); + + // Act & Assert + var actualException = await Assert.ThrowsAsync( + () => _decorator.GetLatestAsync()); + + Assert.Same(expectedException, actualException); + } + + [Fact] + public async Task GetLatestAsync_WithDifferentValidForDates_CachesEachSeparately() + { + // Arrange + var firstDate = new DateOnly(2024, 1, 15); + var secondDate = new DateOnly(2024, 1, 16); + + var firstRates = CreateTestExchangeRates(firstDate); + var secondRates = CreateTestExchangeRates(secondDate); + + // Create a separate decorator instance for second call to avoid cache conflicts + var secondMemoryCache = new MemoryCache(new MemoryCacheOptions()); + var mockLogger = new Mock>(); + var secondDecorator = new CnbCachedExchangeRateProviderDecorator(_mockInnerProvider.Object, secondMemoryCache, + _mockDateTimeProvider.Object, mockLogger.Object); + + _mockInnerProvider.SetupSequence(x => x.GetLatestAsync(It.IsAny())) + .ReturnsAsync(firstRates) + .ReturnsAsync(secondRates); + + // Act + var firstResult = await _decorator.GetLatestAsync(); + var secondResult = await secondDecorator.GetLatestAsync(); + + // Assert + Assert.Equal(firstDate, firstResult[0].ValidFor); + Assert.Equal(secondDate, secondResult[0].ValidFor); + + // Verify both cache entries exist in their respective caches + Assert.True(_memoryCache.TryGetValue("ExchangeRates:ValidFor:2024-01-15", out _), + "First date should be cached in first cache"); + Assert.True(secondMemoryCache.TryGetValue("ExchangeRates:ValidFor:2024-01-16", out _), + "Second date should be cached in second cache"); + + // Verify inner provider was called twice + _mockInnerProvider.Verify(x => x.GetLatestAsync(It.IsAny()), Times.Exactly(2)); + + // Clean up second cache + secondMemoryCache.Dispose(); + } + + [Fact] + public async Task GetLatestAsync_WithConcurrentRequests_CallsInnerProviderOnlyOnce() + { + // Arrange + var exchangeRates = CreateTestExchangeRates(DateOnly.FromDateTime(DateTime.Today)); + var callCount = 0; + var tcs = new TaskCompletionSource(); + + _mockInnerProvider + .Setup(x => x.GetLatestAsync(It.IsAny())) + .Returns(async () => + { + var currentCall = Interlocked.Increment(ref callCount); + if (currentCall == 1) + { + // First call - wait for all concurrent calls to be initiated + await Task.Delay(50); + tcs.SetResult(true); + } + await Task.Delay(50); // Simulate some processing time + return exchangeRates; + }); + + // Act + var tasks = Enumerable.Range(0, 3) // Reduce to 3 concurrent requests + .Select(_ => _decorator.GetLatestAsync()) + .ToArray(); + + var results = await Task.WhenAll(tasks); + + // Assert + Assert.All(results, result => + { + Assert.NotNull(result); + Assert.Equal(2, result.Count); + }); + + // Note: Due to the nature of GetOrCreateAsync, it might not guarantee single call in all scenarios + // This is more of an integration test behavior rather than unit test + // We'll verify that calls are minimized but accept that perfect prevention may not occur + Assert.True(callCount <= 3, $"Expected at most 3 calls, but got {callCount}"); + } + + [Fact] + public async Task GetLatestAsync_ReturnsEmptyListWhenLoaderReturnsNull() + { + // Arrange - This test verifies the null coalescing behavior + var emptyRates = new List().AsReadOnly(); + + _mockInnerProvider + .Setup(x => x.GetLatestAsync(It.IsAny())) + .ReturnsAsync(emptyRates); + + // Act + var result = await _decorator.GetLatestAsync(); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Theory] + [InlineData("2024-01-15", "2024-01-15")] // Monday + [InlineData("2024-01-16", "2024-01-16")] // Tuesday + [InlineData("2024-01-17", "2024-01-17")] // Wednesday + [InlineData("2024-01-18", "2024-01-18")] // Thursday + [InlineData("2024-01-19", "2024-01-19")] // Friday + public async Task GetLatestAsync_WithDifferentBusinessDays_CachesCorrectly(string inputDateStr, string expectedDateStr) + { + // Arrange + var inputDate = DateOnly.Parse(inputDateStr); + var expectedDate = DateOnly.Parse(expectedDateStr); + var exchangeRates = CreateTestExchangeRates(inputDate); + + _mockInnerProvider + .Setup(x => x.GetLatestAsync(It.IsAny())) + .ReturnsAsync(exchangeRates); + + // Act + var result = await _decorator.GetLatestAsync(); + + // Assert + Assert.NotNull(result); + Assert.Equal(expectedDate, result[0].ValidFor); + + var expectedKey = $"ExchangeRates:ValidFor:{expectedDateStr}"; + Assert.True(_memoryCache.TryGetValue(expectedKey, out _)); + } + + private static ReadOnlyCollection CreateTestExchangeRates(DateOnly validFor) + { + return new List + { + new(new Currency("USD"), new Currency("CZK"), 25.5m, validFor), + new(new Currency("EUR"), new Currency("CZK"), 24.8m, validFor) + }.AsReadOnly(); + } + + public void Dispose() + { + _memoryCache.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Infrastructure.Tests/ExchangeRateProviders/Cnb/CnbExchangeRateProviderTests.cs b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Infrastructure.Tests/ExchangeRateProviders/Cnb/CnbExchangeRateProviderTests.cs new file mode 100644 index 0000000000..c3fa7e14f4 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Infrastructure.Tests/ExchangeRateProviders/Cnb/CnbExchangeRateProviderTests.cs @@ -0,0 +1,423 @@ +using ExchangeRateProvider.Domain.Entities; +using ExchangeRateProvider.Infrastructure.ExchangeRateProviders.Cnb; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Options; +using Moq; +using Moq.Protected; +using System.Net; +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; + +namespace ExchangeRateProvider.Infrastructure.Tests.ExchangeRateProviders.Cnb; + +public class CnbExchangeRateProviderTests : IDisposable +{ + private readonly Mock _mockHttpClientFactory; + private readonly Mock _mockHttpMessageHandler; + private readonly HttpClient _httpClient; + private readonly CnbApiOptions _cnbApiOptions; + private readonly CnbExchangeRateProvider _provider; + + public CnbExchangeRateProviderTests() + { + _mockHttpClientFactory = new Mock(); + var mockOptions = new Mock>(); + _mockHttpMessageHandler = new Mock(); + + _cnbApiOptions = new CnbApiOptions + { + HttpClientName = "CnbApiClient", + BaseUrl = "https://api.cnb.cz", + DailyExchangeRatesEndpoint = "/cnbapi/exrates/daily" + }; + + mockOptions.Setup(x => x.Value).Returns(_cnbApiOptions); + + _httpClient = new HttpClient(_mockHttpMessageHandler.Object) + { + BaseAddress = new Uri(_cnbApiOptions.BaseUrl) + }; + + _mockHttpClientFactory + .Setup(x => x.CreateClient(_cnbApiOptions.HttpClientName)) + .Returns(_httpClient); + + var mockLogger = new Mock>(); + + _provider = new CnbExchangeRateProvider(mockOptions.Object, _mockHttpClientFactory.Object, mockLogger.Object); + } + + [Fact] + public async Task GetLatestAsync_WithValidResponse_ReturnsCorrectExchangeRates() + { + // Arrange + var cnbResponseData = new CnbExchangeRates + { + Rates = + [ + new CnbExchangeRate + { + Amount = 1, + Country = "USA", + Currency = "dollar", + CurrencyCode = "USD", + Order = 840, + Rate = 25.5m, + ValidFor = DateOnly.FromDateTime(DateTime.Today) + }, + new CnbExchangeRate + { + Amount = 1, + Country = "EMU", + Currency = "euro", + CurrencyCode = "EUR", + Order = 978, + Rate = 24.8m, + ValidFor = DateOnly.FromDateTime(DateTime.Today) + }, + new CnbExchangeRate + { + Amount = 100, + Country = "Japan", + Currency = "yen", + CurrencyCode = "JPY", + Order = 392, + Rate = 16.85m, + ValidFor = DateOnly.FromDateTime(DateTime.Today) + } + ] + }; + + var jsonResponse = JsonSerializer.Serialize(cnbResponseData); + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(jsonResponse, Encoding.UTF8, MediaTypeHeaderValue.Parse("application/json")) + }; + + _mockHttpMessageHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.Is(req => req.Method == HttpMethod.Get), + ItExpr.IsAny()) + .ReturnsAsync(httpResponse); + + // Act + var result = await _provider.GetLatestAsync(); + + // Assert + Assert.NotNull(result); + Assert.Equal(3, result.Count); + + var usdRate = result.First(r => r.SourceCurrency.Code == "USD"); + Assert.Equal(new Currency("USD"), usdRate.SourceCurrency); + Assert.Equal(new Currency("CZK"), usdRate.TargetCurrency); + Assert.Equal(25.5m, usdRate.Value); // 25.5 / 1 + Assert.Equal(DateOnly.FromDateTime(DateTime.Today), usdRate.ValidFor); + + var eurRate = result.First(r => r.SourceCurrency.Code == "EUR"); + Assert.Equal(24.8m, eurRate.Value); // 24.8 / 1 + + var jpyRate = result.First(r => r.SourceCurrency.Code == "JPY"); + Assert.Equal(0.1685m, jpyRate.Value); // 16.85 / 100 + } + + [Fact] + public async Task GetLatestAsync_WithEmptyResponse_ReturnsEmptyList() + { + // Arrange + var cnbResponseData = new CnbExchangeRates + { + Rates = [] + }; + + var jsonResponse = JsonSerializer.Serialize(cnbResponseData); + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(jsonResponse, Encoding.UTF8, MediaTypeHeaderValue.Parse("application/json")) + }; + + _mockHttpMessageHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(httpResponse); + + // Act + var result = await _provider.GetLatestAsync(); + + // Assert + Assert.NotNull(result); + Assert.Empty(result); + } + + [Fact] + public async Task GetLatestAsync_ConstructsCorrectUrl() + { + // Arrange + var cnbResponseData = new CnbExchangeRates { Rates = [] }; + var jsonResponse = JsonSerializer.Serialize(cnbResponseData); + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(jsonResponse, Encoding.UTF8, MediaTypeHeaderValue.Parse("application/json")) + }; + + HttpRequestMessage? capturedRequest = null; + _mockHttpMessageHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((request, _) => capturedRequest = request) + .ReturnsAsync(httpResponse); + + // Act + await _provider.GetLatestAsync(); + + // Assert + Assert.NotNull(capturedRequest); + Assert.Contains("lang=EN", capturedRequest.RequestUri?.Query); + Assert.Contains(_cnbApiOptions.DailyExchangeRatesEndpoint, capturedRequest.RequestUri?.AbsolutePath); + } + + [Fact] + public async Task GetLatestAsync_WithCancellationToken_PassesTokenToHttpClient() + { + // Arrange + var cancellationTokenSource = new CancellationTokenSource(); + var cancellationToken = cancellationTokenSource.Token; + + var cnbResponseData = new CnbExchangeRates { Rates = [] }; + var jsonResponse = JsonSerializer.Serialize(cnbResponseData); + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(jsonResponse, Encoding.UTF8, MediaTypeHeaderValue.Parse("application/json")) + }; + + var capturedTokenIsCancellationRequested = false; + _mockHttpMessageHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .Callback((_, token) => + { + capturedTokenIsCancellationRequested = token.IsCancellationRequested; + }) + .ReturnsAsync(httpResponse); + + // Act + await _provider.GetLatestAsync(cancellationToken); + + // Assert + Assert.False(capturedTokenIsCancellationRequested); // Token should not be cancelled + _mockHttpMessageHandler.Protected().Verify( + "SendAsync", + Times.Once(), + ItExpr.IsAny(), + ItExpr.IsAny()); + } + + [Fact] + public async Task GetLatestAsync_WithHttpException_PropagatesException() + { + // Arrange + var expectedException = new HttpRequestException("Network error"); + + _mockHttpMessageHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ThrowsAsync(expectedException); + + // Act & Assert + var actualException = await Assert.ThrowsAsync( + () => _provider.GetLatestAsync()); + + Assert.Same(expectedException, actualException); + } + + [Fact] + public async Task GetLatestAsync_WithNonSuccessStatusCode_ThrowsHttpRequestException() + { + // Arrange + var httpResponse = new HttpResponseMessage(HttpStatusCode.InternalServerError) + { + Content = new StringContent("Internal server error") + }; + + _mockHttpMessageHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(httpResponse); + + // Act & Assert + await Assert.ThrowsAsync( + () => _provider.GetLatestAsync()); + } + + [Fact] + public async Task GetLatestAsync_WithInvalidJsonResponse_ThrowsJsonException() + { + // Arrange + const string invalidJsonResponse = "{ invalid json }"; + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(invalidJsonResponse, Encoding.UTF8, MediaTypeHeaderValue.Parse("application/json")) + }; + + _mockHttpMessageHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(httpResponse); + + // Act & Assert + await Assert.ThrowsAsync( + () => _provider.GetLatestAsync()); + } + + [Fact] + public async Task GetLatestAsync_UsesCorrectHttpClientName() + { + // Arrange + var cnbResponseData = new CnbExchangeRates { Rates = [] }; + var jsonResponse = JsonSerializer.Serialize(cnbResponseData); + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(jsonResponse, Encoding.UTF8, MediaTypeHeaderValue.Parse("application/json")) + }; + + _mockHttpMessageHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(httpResponse); + + // Act + await _provider.GetLatestAsync(); + + // Assert + _mockHttpClientFactory.Verify( + x => x.CreateClient(_cnbApiOptions.HttpClientName), + Times.Once); + } + + [Fact] + public async Task GetLatestAsync_WithDifferentAmounts_CalculatesCorrectRates() + { + // Arrange + var cnbResponseData = new CnbExchangeRates + { + Rates = + [ + new CnbExchangeRate + { + Amount = 10, + CurrencyCode = "SEK", + Rate = 23.45m, + Country = "Sweden", + Currency = "krona", + Order = 752, + ValidFor = DateOnly.FromDateTime(DateTime.Today) + }, + new CnbExchangeRate + { + Amount = 1000, + CurrencyCode = "KRW", + Rate = 18.92m, + Country = "South Korea", + Currency = "won", + Order = 410, + ValidFor = DateOnly.FromDateTime(DateTime.Today) + } + ] + }; + + var jsonResponse = JsonSerializer.Serialize(cnbResponseData); + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(jsonResponse, Encoding.UTF8, MediaTypeHeaderValue.Parse("application/json")) + }; + + _mockHttpMessageHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(httpResponse); + + // Act + var result = await _provider.GetLatestAsync(); + + // Assert + var sekRate = result.First(r => r.SourceCurrency.Code == "SEK"); + Assert.Equal(2.345m, sekRate.Value); // 23.45 / 10 + + var krwRate = result.First(r => r.SourceCurrency.Code == "KRW"); + Assert.Equal(0.01892m, krwRate.Value); // 18.92 / 1000 + } + + [Fact] + public async Task GetLatestAsync_AllExchangeRates_HaveCzkAsTargetCurrency() + { + // Arrange + var cnbResponseData = new CnbExchangeRates + { + Rates = + [ + new CnbExchangeRate + { + Amount = 1, + CurrencyCode = "USD", + Rate = 25.5m, + Country = "USA", + Currency = "dollar", + Order = 840, + ValidFor = DateOnly.FromDateTime(DateTime.Today) + }, + new CnbExchangeRate + { + Amount = 1, + CurrencyCode = "EUR", + Rate = 24.8m, + Country = "EMU", + Currency = "euro", + Order = 978, + ValidFor = DateOnly.FromDateTime(DateTime.Today) + } + ] + }; + + var jsonResponse = JsonSerializer.Serialize(cnbResponseData); + var httpResponse = new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(jsonResponse, Encoding.UTF8, MediaTypeHeaderValue.Parse("application/json")) + }; + + _mockHttpMessageHandler.Protected() + .Setup>( + "SendAsync", + ItExpr.IsAny(), + ItExpr.IsAny()) + .ReturnsAsync(httpResponse); + + // Act + var result = await _provider.GetLatestAsync(); + + // Assert + Assert.All(result, rate => Assert.Equal(new Currency("CZK"), rate.TargetCurrency)); + } + + public void Dispose() + { + _httpClient.Dispose(); + GC.SuppressFinalize(this); + } +} diff --git a/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Infrastructure.Tests/ExchangeRateProviders/HttpClientBuilderExtensionsTests.cs b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Infrastructure.Tests/ExchangeRateProviders/HttpClientBuilderExtensionsTests.cs new file mode 100644 index 0000000000..32b3a0b47a --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Infrastructure.Tests/ExchangeRateProviders/HttpClientBuilderExtensionsTests.cs @@ -0,0 +1,109 @@ +using ExchangeRateProvider.Infrastructure.ExchangeRateProviders; +using Microsoft.Extensions.DependencyInjection; + +namespace ExchangeRateProvider.Infrastructure.Tests.ExchangeRateProviders; + +public class HttpClientBuilderExtensionsTests +{ + private readonly IServiceCollection _services; + private readonly IHttpClientBuilder _httpClientBuilder; + + public HttpClientBuilderExtensionsTests() + { + _services = new ServiceCollection(); + _httpClientBuilder = _services.AddHttpClient("test-client"); + } + + [Fact] + public void AddResilience_WithValidName_DoesNotThrow() + { + // Act & Assert + var exception = Record.Exception(() => _httpClientBuilder.AddResilience("test-resilience")); + + Assert.Null(exception); + } + + [Fact] + public void AddResilience_ReturnsHttpResiliencePipelineBuilder() + { + // Act + var result = _httpClientBuilder.AddResilience("test-resilience"); + + // Assert + Assert.NotNull(result); + } + + [Fact] + public void AddResilience_RegistersResilienceHandler() + { + // Act + _httpClientBuilder.AddResilience("test-resilience"); + + // Assert + var serviceProvider = _services.BuildServiceProvider(); + var httpClientFactory = serviceProvider.GetRequiredService(); + + Assert.NotNull(httpClientFactory); + + // Verify that the client can be created without throwing + var exception = Record.Exception(() => httpClientFactory.CreateClient("test-client")); + Assert.Null(exception); + } + + [Fact] + public void AddResilience_WithEmptyName_ThrowsArgumentException() + { + // Act & Assert + Assert.Throws(() => _httpClientBuilder.AddResilience("")); + } + + [Fact] + public void AddResilience_WithNullName_ThrowsArgumentNullException() + { + // Act & Assert + Assert.Throws(() => _httpClientBuilder.AddResilience(null!)); + } + + [Fact] + public void AddResilience_CanBeCalledMultipleTimes() + { + // Act & Assert + var exception = Record.Exception(() => + { + _httpClientBuilder.AddResilience("resilience-1"); + _httpClientBuilder.AddResilience("resilience-2"); + }); + + Assert.Null(exception); + } + + [Fact] + public void AddResilience_CreatesHttpClientSuccessfully() + { + // Arrange + _httpClientBuilder.AddResilience("test-resilience"); + var serviceProvider = _services.BuildServiceProvider(); + var httpClientFactory = serviceProvider.GetRequiredService(); + + // Act + var httpClient = httpClientFactory.CreateClient("test-client"); + + // Assert + Assert.NotNull(httpClient); + // Note: Polly timeout is handled by the resilience pipeline, not HttpClient.Timeout + // HttpClient.Timeout remains at its default value while Polly handles the actual timeout + Assert.True(httpClient.Timeout > TimeSpan.Zero); + } + + [Theory] + [InlineData("test")] + [InlineData("my-resilience-handler")] + [InlineData("cnb-api-resilience")] + public void AddResilience_WithVariousNames_DoesNotThrow(string handlerName) + { + // Act & Assert + var exception = Record.Exception(() => _httpClientBuilder.AddResilience(handlerName)); + + Assert.Null(exception); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Infrastructure.Tests/ExchangeRateProviders/ResilienceConfigurationTests.cs b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Infrastructure.Tests/ExchangeRateProviders/ResilienceConfigurationTests.cs new file mode 100644 index 0000000000..dc85744fcc --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Infrastructure.Tests/ExchangeRateProviders/ResilienceConfigurationTests.cs @@ -0,0 +1,94 @@ +using ExchangeRateProvider.Infrastructure.ExchangeRateProviders; + +namespace ExchangeRateProvider.Infrastructure.Tests.ExchangeRateProviders; + +public class ResilienceConfigurationTests +{ + [Fact] + public void Timeout_HasExpectedValue() + { + // Act & Assert + Assert.Equal(TimeSpan.FromSeconds(10), ResilienceConfiguration.Timeout); + } + + [Fact] + public void MaxRetryAttempts_HasExpectedValue() + { + // Act & Assert + Assert.Equal(3, ResilienceConfiguration.MaxRetryAttempts); + } + + [Fact] + public void InitialRetryDelay_HasExpectedValue() + { + // Act & Assert + Assert.Equal(TimeSpan.FromMilliseconds(200), ResilienceConfiguration.InitialRetryDelay); + } + + [Fact] + public void UseJitter_IsTrue() + { + // Act & Assert + Assert.True(ResilienceConfiguration.UseJitter); + } + + [Fact] + public void CircuitBreakerSamplingDuration_HasExpectedValue() + { + // Act & Assert + Assert.Equal(TimeSpan.FromSeconds(30), ResilienceConfiguration.CircuitBreakerSamplingDuration); + } + + [Fact] + public void CircuitBreakerFailureRatio_HasExpectedValue() + { + // Act & Assert + Assert.Equal(0.5, ResilienceConfiguration.CircuitBreakerFailureRatio); + } + + [Fact] + public void CircuitBreakerMinimumThroughput_HasExpectedValue() + { + // Act & Assert + Assert.Equal(10, ResilienceConfiguration.CircuitBreakerMinimumThroughput); + } + + [Fact] + public void CircuitBreakerBreakDuration_HasExpectedValue() + { + // Act & Assert + Assert.Equal(TimeSpan.FromSeconds(30), ResilienceConfiguration.CircuitBreakerBreakDuration); + } + + [Fact] + public void CircuitBreakerFailureRatio_IsWithinValidRange() + { + // Act & Assert + Assert.True(ResilienceConfiguration.CircuitBreakerFailureRatio >= 0.0); + Assert.True(ResilienceConfiguration.CircuitBreakerFailureRatio <= 1.0); + } + + [Fact] + public void MaxRetryAttempts_IsPositive() + { + // Act & Assert + Assert.True(ResilienceConfiguration.MaxRetryAttempts > 0); + } + + [Fact] + public void CircuitBreakerMinimumThroughput_IsPositive() + { + // Act & Assert + Assert.True(ResilienceConfiguration.CircuitBreakerMinimumThroughput > 0); + } + + [Fact] + public void AllTimeSpanValues_ArePositive() + { + // Act & Assert + Assert.True(ResilienceConfiguration.Timeout > TimeSpan.Zero, "Timeout should be positive"); + Assert.True(ResilienceConfiguration.InitialRetryDelay > TimeSpan.Zero, "InitialRetryDelay should be positive"); + Assert.True(ResilienceConfiguration.CircuitBreakerSamplingDuration > TimeSpan.Zero, "CircuitBreakerSamplingDuration should be positive"); + Assert.True(ResilienceConfiguration.CircuitBreakerBreakDuration > TimeSpan.Zero, "CircuitBreakerBreakDuration should be positive"); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Infrastructure.Tests/ServiceCollectionExtensionsTests.cs b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Infrastructure.Tests/ServiceCollectionExtensionsTests.cs new file mode 100644 index 0000000000..0451d9ade9 --- /dev/null +++ b/jobs/Backend/Task/ExchangeRatePovider/test/ExchangeRateProvider.Infrastructure.Tests/ServiceCollectionExtensionsTests.cs @@ -0,0 +1,302 @@ +using ExchangeRateProvider.Application.Interfaces; +using ExchangeRateProvider.Infrastructure.ExchangeRateProviders; +using ExchangeRateProvider.Infrastructure.ExchangeRateProviders.Cnb; +using Microsoft.Extensions.Caching.Memory; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Options; + +namespace ExchangeRateProvider.Infrastructure.Tests; + +public class ServiceCollectionExtensionsTests +{ + private readonly IConfiguration _configuration; + + public ServiceCollectionExtensionsTests() + { + // Setup configuration with CNB API options + var configurationBuilder = new ConfigurationBuilder(); + configurationBuilder.AddInMemoryCollection(new Dictionary + { + ["CnbApi:HttpClientName"] = "CnbApiClient", + ["CnbApi:BaseUrl"] = "https://api.cnb.cz", + ["CnbApi:DailyExchangeRatesEndpoint"] = "/cnbapi/exrates/daily" + }!); + _configuration = configurationBuilder.Build(); + } + + [Fact] + public void AddInfrastructureServices_RegistersCnbApiOptions_Correctly() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddInfrastructureServices(_configuration); + + // Assert + var serviceProvider = services.BuildServiceProvider(); + var options = serviceProvider.GetRequiredService>(); + + Assert.NotNull(options); + Assert.NotNull(options.Value); + Assert.Equal("CnbApiClient", options.Value.HttpClientName); + Assert.Equal("https://api.cnb.cz", options.Value.BaseUrl); + Assert.Equal("/cnbapi/exrates/daily", options.Value.DailyExchangeRatesEndpoint); + } + + [Fact] + public void AddInfrastructureServices_RegistersMemoryCache_AsSingleton() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddInfrastructureServices(_configuration); + + // Assert + var cacheDescriptor = services.FirstOrDefault(s => s.ServiceType == typeof(IMemoryCache)); + + Assert.NotNull(cacheDescriptor); + Assert.Equal(ServiceLifetime.Singleton, cacheDescriptor.Lifetime); + } + + [Fact] + public void AddInfrastructureServices_RegistersCacheDateTimeProvider_AsSingleton() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddInfrastructureServices(_configuration); + + // Assert + var dateTimeProviderDescriptor = services.FirstOrDefault(s => s.ServiceType == typeof(ICacheDateTimeProvider)); + + Assert.NotNull(dateTimeProviderDescriptor); + Assert.Equal(typeof(CnbCacheDateTimeProvider), dateTimeProviderDescriptor.ImplementationType); + Assert.Equal(ServiceLifetime.Singleton, dateTimeProviderDescriptor.Lifetime); + } + + [Fact] + public void AddInfrastructureServices_RegistersCnbExchangeRateProvider_AsScoped() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddInfrastructureServices(_configuration); + + // Assert + var cnbProviderDescriptor = services.FirstOrDefault(s => + s.ServiceType == typeof(CnbExchangeRateProvider)); + + Assert.NotNull(cnbProviderDescriptor); + Assert.Equal(ServiceLifetime.Scoped, cnbProviderDescriptor.Lifetime); + } + + [Fact] + public void AddInfrastructureServices_RegistersIExchangeRateProvider_AsScoped() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddInfrastructureServices(_configuration); + + // Assert + // Note: IExchangeRateProvider is registered using a factory delegate, so it appears as Transient + // but the actual implementation will respect the lifetime of its dependencies + var exchangeRateProviderDescriptor = services.FirstOrDefault(s => + s.ServiceType == typeof(IExchangeRateProvider)); + + Assert.NotNull(exchangeRateProviderDescriptor); + // Factory registrations appear as Transient, but the actual behavior depends on dependencies + Assert.Equal(ServiceLifetime.Transient, exchangeRateProviderDescriptor.Lifetime); + } + + [Fact] + public void AddInfrastructureServices_CanResolveAllServices_WhenProperlyConfigured() + { + // Arrange + var services = new ServiceCollection(); + services.AddInfrastructureServices(_configuration); + + using var serviceProvider = services.BuildServiceProvider(); + + // Act & Assert + var memoryCache = serviceProvider.GetService(); + Assert.NotNull(memoryCache); + + var dateTimeProvider = serviceProvider.GetService(); + Assert.NotNull(dateTimeProvider); + Assert.IsType(dateTimeProvider); + + var cnbProvider = serviceProvider.GetService(); + Assert.NotNull(cnbProvider); + + var exchangeRateProvider = serviceProvider.GetService(); + Assert.NotNull(exchangeRateProvider); + Assert.IsType(exchangeRateProvider); + } + + [Fact] + public void AddInfrastructureServices_ReturnsServiceCollection_ForMethodChaining() + { + // Arrange + var services = new ServiceCollection(); + + // Act + var result = services.AddInfrastructureServices(_configuration); + + // Assert + Assert.Same(services, result); + } + + [Fact] + public void AddInfrastructureServices_WithExistingServices_PreservesExistingRegistrations() + { + // Arrange + var services = new ServiceCollection(); + services.AddSingleton("existing-service"); + + var initialCount = services.Count; + + // Act + services.AddInfrastructureServices(_configuration); + + // Assert + Assert.Contains(services, s => s.ServiceType == typeof(string)); + Assert.True(services.Count > initialCount, "Should have added new services"); + } + + [Fact] + public void AddInfrastructureServices_RegistersHttpClient_WithCorrectConfiguration() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddInfrastructureServices(_configuration); + + using var serviceProvider = services.BuildServiceProvider(); + + // Assert + var httpClientFactory = serviceProvider.GetRequiredService(); + Assert.NotNull(httpClientFactory); + + // Verify we can create the named HTTP client + var httpClient = httpClientFactory.CreateClient("CnbApiClient"); + Assert.NotNull(httpClient); + Assert.Equal(new Uri("https://api.cnb.cz"), httpClient.BaseAddress); + Assert.Contains("application/json", httpClient.DefaultRequestHeaders.Accept.ToString()); + } + + [Fact] + public void AddInfrastructureServices_IExchangeRateProviderResolvesToDecorator() + { + // Arrange + var services = new ServiceCollection(); + services.AddInfrastructureServices(_configuration); + + using var serviceProvider = services.BuildServiceProvider(); + + // Act + var exchangeRateProvider = serviceProvider.GetRequiredService(); + + // Assert + Assert.IsType(exchangeRateProvider); + } + + [Fact] + public void AddInfrastructureServices_CreatesNewInstancePerScope_ForScopedServices() + { + // Arrange + var services = new ServiceCollection(); + services.AddInfrastructureServices(_configuration); + + using var serviceProvider = services.BuildServiceProvider(); + + // Act - Test scoped services behavior + CnbExchangeRateProvider cnbService1, cnbService2, cnbService3, cnbService4; + + using (var scope1 = serviceProvider.CreateScope()) + { + cnbService1 = scope1.ServiceProvider.GetRequiredService(); + cnbService2 = scope1.ServiceProvider.GetRequiredService(); + } + + using (var scope2 = serviceProvider.CreateScope()) + { + cnbService3 = scope2.ServiceProvider.GetRequiredService(); + cnbService4 = scope2.ServiceProvider.GetRequiredService(); + } + + // Assert - CNB provider should be scoped + Assert.Same(cnbService1, cnbService2); // Same within scope + Assert.Same(cnbService3, cnbService4); // Same within scope + Assert.NotSame(cnbService1, cnbService3); // Different across scopes + } + + [Fact] + public void AddInfrastructureServices_CalledMultipleTimes_RegistersServicesMultipleTimes() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddInfrastructureServices(_configuration); + var initialCount = services.Count; + services.AddInfrastructureServices(_configuration); + var finalCount = services.Count; + + // Assert - Should have added more services + Assert.True(finalCount > initialCount, "Should have registered additional services"); + + // Check that key services exist (allowing for multiple registrations) + Assert.Contains(services, s => s.ServiceType == typeof(IExchangeRateProvider)); + Assert.Contains(services, s => s.ServiceType == typeof(CnbExchangeRateProvider)); + Assert.Contains(services, s => s.ServiceType == typeof(ICacheDateTimeProvider)); + } + + [Fact] + public void AddInfrastructureServices_RegistersCorrectNumberOfServices() + { + // Arrange + var services = new ServiceCollection(); + var initialCount = services.Count; + + // Act + services.AddInfrastructureServices(_configuration); + + // Assert + Assert.True(services.Count > initialCount, "Should have registered new services"); + + // Verify specific services are registered (without requiring IOptions specifically) + Assert.Contains(services, s => s.ServiceType == typeof(IMemoryCache)); + Assert.Contains(services, s => s.ServiceType == typeof(ICacheDateTimeProvider)); + Assert.Contains(services, s => s.ServiceType == typeof(CnbExchangeRateProvider)); + Assert.Contains(services, s => s.ServiceType == typeof(IExchangeRateProvider)); + + // Check that options configuration is present (could be generic IOptions or specific) + Assert.Contains(services, s => s.ServiceType.IsGenericType && + s.ServiceType.GetGenericTypeDefinition() == typeof(IOptions<>)); + } + + [Fact] + public void AddInfrastructureServices_ConfiguresOptionsCorrectly() + { + // Arrange + var services = new ServiceCollection(); + + // Act + services.AddInfrastructureServices(_configuration); + + // Assert + var optionsDescriptor = services.FirstOrDefault(s => + s.ServiceType == typeof(IConfigureOptions)); + + Assert.NotNull(optionsDescriptor); + Assert.Equal(ServiceLifetime.Singleton, optionsDescriptor.Lifetime); + } +} \ No newline at end of file diff --git a/jobs/Backend/Task/ExchangeRateProvider.cs b/jobs/Backend/Task/ExchangeRateProvider.cs deleted file mode 100644 index 6f82a97fbe..0000000000 --- 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 2fc654a12b..0000000000 --- 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 deleted file mode 100644 index 89be84daff..0000000000 --- a/jobs/Backend/Task/ExchangeRateUpdater.sln +++ /dev/null @@ -1,22 +0,0 @@ - -Microsoft Visual Studio Solution File, Format Version 12.00 -# Visual Studio 14 -VisualStudioVersion = 14.0.25123.0 -MinimumVisualStudioVersion = 10.0.40219.1 -Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "ExchangeRateUpdater", "ExchangeRateUpdater.csproj", "{7B2695D6-D24C-4460-A58E-A10F08550CE0}" -EndProject -Global - GlobalSection(SolutionConfigurationPlatforms) = preSolution - Debug|Any CPU = Debug|Any CPU - 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 - EndGlobalSection - GlobalSection(SolutionProperties) = preSolution - HideSolutionNode = FALSE - EndGlobalSection -EndGlobal diff --git a/jobs/Backend/Task/Program.cs b/jobs/Backend/Task/Program.cs deleted file mode 100644 index 379a69b1f8..0000000000 --- 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(); - } - } -}