diff --git a/src/Infrastructure/Services/Mediator/ServiceProviderExtensions.cs b/src/Infrastructure/Services/Mediator/ServiceProviderExtensions.cs index bdd542a2..b908e50d 100644 --- a/src/Infrastructure/Services/Mediator/ServiceProviderExtensions.cs +++ b/src/Infrastructure/Services/Mediator/ServiceProviderExtensions.cs @@ -8,7 +8,7 @@ public static async Task HandleBy( this IServiceProvider serviceProvider, TRequest request, CancellationToken cancellationToken = default) - where THandler : Infrastructure.Services.Mediator.IRequestHandler + where THandler : IRequestHandler { var handler = serviceProvider.GetRequiredService(); return await handler.Handle(request, cancellationToken); diff --git a/src/Web.Api.Tests/Features/Currencies/CreateCurrenciesCollection/CreateCurrenciesCollectionHandlerTests.cs b/src/Web.Api.Tests/Features/Currencies/CreateCurrenciesCollection/CreateCurrenciesCollectionHandlerTests.cs new file mode 100644 index 00000000..4120c963 --- /dev/null +++ b/src/Web.Api.Tests/Features/Currencies/CreateCurrenciesCollection/CreateCurrenciesCollectionHandlerTests.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Domain.Entities.Salaries; +using Domain.Validation.Exceptions; +using Microsoft.EntityFrameworkCore; +using TestUtils.Db; +using TestUtils.Fakes; +using Web.Api.Features.Currencies.CreateCurrenciesCollection; +using Xunit; + +namespace Web.Api.Tests.Features.Currencies.CreateCurrenciesCollection; + +public class CreateCurrenciesCollectionHandlerTests +{ + [Fact] + public async Task Handle_NoExistingRecordForDate_ValidData_CreatesNewRecord() + { + // Arrange + await using var context = new InMemoryDatabaseContext(); + var handler = new CreateCurrenciesCollectionHandler(context); + + var currencyDate = new DateTime(2024, 1, 15); + var request = new CreateCurrenciesCollectionRequest + { + CurrencyDate = currencyDate, + Currencies = new Dictionary + { + { Currency.USD, 450.50 }, + { Currency.EUR, 520.75 }, + { Currency.KZT, 1.0 } + } + }; + + Assert.Equal(0, context.CurrencyCollections.Count()); + + // Act + var result = await handler.Handle(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(currencyDate, result.CurrencyDate); + Assert.Equal(3, result.Currencies.Count); + + var createdRecord = await context.CurrencyCollections + .FirstOrDefaultAsync(x => x.CurrencyDate == currencyDate); + + Assert.NotNull(createdRecord); + Assert.Equal(currencyDate, createdRecord.CurrencyDate); + Assert.Equal(3, createdRecord.Currencies.Count); + Assert.Equal(450.50, createdRecord.Currencies[Currency.USD]); + Assert.Equal(520.75, createdRecord.Currencies[Currency.EUR]); + Assert.Equal(1.0, createdRecord.Currencies[Currency.KZT]); + Assert.Equal(1, context.CurrencyCollections.Count()); + } + + [Fact] + public async Task Handle_ExistingRecordForDate_ValidData_ThrowsBadRequestException() + { + // Arrange + await using var context = new InMemoryDatabaseContext(); + var currencyDate = new DateTime(2024, 1, 15); + + var existingRecord = new CurrenciesCollectionFake(currencyDate).Please(context); + + var handler = new CreateCurrenciesCollectionHandler(context); + + var request = new CreateCurrenciesCollectionRequest + { + CurrencyDate = currencyDate, + Currencies = new Dictionary + { + { Currency.USD, 460.00 }, + { Currency.EUR, 530.00 } + } + }; + + Assert.Equal(1, context.CurrencyCollections.Count()); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + handler.Handle(request, CancellationToken.None)); + + Assert.Contains("already exists", exception.Message); + + var allRecords = context.CurrencyCollections.ToList(); + Assert.Single(allRecords); + Assert.Equal(existingRecord.Id, allRecords[0].Id); + } + + [Fact] + public async Task Handle_NoCurrencies_ThrowsBadRequestException() + { + // Arrange + await using var context = new InMemoryDatabaseContext(); + var handler = new CreateCurrenciesCollectionHandler(context); + + var request = new CreateCurrenciesCollectionRequest + { + CurrencyDate = new DateTime(2024, 1, 15), + Currencies = new Dictionary() + }; + + Assert.Equal(0, context.CurrencyCollections.Count()); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + handler.Handle(request, CancellationToken.None)); + + Assert.Contains("at least one currency", exception.Message); + Assert.Equal(0, context.CurrencyCollections.Count()); + } + + [Fact] + public async Task Handle_FutureDate_ThrowsBadRequestException() + { + // Arrange + await using var context = new InMemoryDatabaseContext(); + var handler = new CreateCurrenciesCollectionHandler(context); + + var futureDate = DateTime.UtcNow.Date.AddDays(30); + var request = new CreateCurrenciesCollectionRequest + { + CurrencyDate = futureDate, + Currencies = new Dictionary + { + { Currency.USD, 450.00 }, + { Currency.EUR, 520.00 } + } + }; + + Assert.Equal(0, context.CurrencyCollections.Count()); + + // Act & Assert + var exception = await Assert.ThrowsAsync(() => + handler.Handle(request, CancellationToken.None)); + + Assert.Contains("future", exception.Message, StringComparison.OrdinalIgnoreCase); + Assert.Equal(0, context.CurrencyCollections.Count()); + } +} diff --git a/src/Web.Api/Features/Currencies/CreateCurrenciesCollection/CreateCurrenciesCollectionHandler.cs b/src/Web.Api/Features/Currencies/CreateCurrenciesCollection/CreateCurrenciesCollectionHandler.cs new file mode 100644 index 00000000..27269ee3 --- /dev/null +++ b/src/Web.Api/Features/Currencies/CreateCurrenciesCollection/CreateCurrenciesCollectionHandler.cs @@ -0,0 +1,52 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Domain.Entities.Currencies; +using Domain.Validation.Exceptions; +using Infrastructure.Database; +using Infrastructure.Services.Mediator; +using Microsoft.EntityFrameworkCore; +using Web.Api.Features.Currencies.Dtos; + +namespace Web.Api.Features.Currencies.CreateCurrenciesCollection; + +public class CreateCurrenciesCollectionHandler( + DatabaseContext context) + : IRequestHandler +{ + public async Task Handle( + CreateCurrenciesCollectionRequest request, + CancellationToken cancellationToken) + { + if (request.Currencies == null || request.Currencies.Count == 0) + { + throw new BadRequestException("Currencies collection must have at least one currency."); + } + + if (request.CurrencyDate > System.DateTime.UtcNow.Date) + { + throw new BadRequestException("Currency date cannot be in the future."); + } + + var existingRecord = await context.CurrencyCollections + .AsNoTracking() + .FirstOrDefaultAsync(x => x.CurrencyDate == request.CurrencyDate, cancellationToken); + + if (existingRecord != null) + { + throw new BadRequestException($"A currency collection for date {request.CurrencyDate:yyyy-MM-dd} already exists."); + } + + var currencyContents = request.Currencies + .Select(x => new CurrencyContent(x.Value, x.Key, request.CurrencyDate)) + .ToDictionary(x => x.Currency, x => x); + + var entity = new CurrenciesCollection(currencyContents); + + context.CurrencyCollections.Add(entity); + await context.TrySaveChangesAsync(cancellationToken); + + return new CurrenciesCollectionDto(entity); + } +} diff --git a/src/Web.Api/Features/Currencies/CreateCurrenciesCollection/CreateCurrenciesCollectionRequest.cs b/src/Web.Api/Features/Currencies/CreateCurrenciesCollection/CreateCurrenciesCollectionRequest.cs new file mode 100644 index 00000000..6eff050e --- /dev/null +++ b/src/Web.Api/Features/Currencies/CreateCurrenciesCollection/CreateCurrenciesCollectionRequest.cs @@ -0,0 +1,15 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations; +using Domain.Entities.Salaries; + +namespace Web.Api.Features.Currencies.CreateCurrenciesCollection; + +public record CreateCurrenciesCollectionRequest +{ + [Required] + public Dictionary Currencies { get; init; } + + [Required] + public DateTime CurrencyDate { get; init; } +} diff --git a/src/Web.Api/Features/Currencies/CurrenciesCollectionController.cs b/src/Web.Api/Features/Currencies/CurrenciesCollectionController.cs new file mode 100644 index 00000000..28700714 --- /dev/null +++ b/src/Web.Api/Features/Currencies/CurrenciesCollectionController.cs @@ -0,0 +1,71 @@ +using System; +using System.Threading; +using System.Threading.Tasks; +using Domain.Enums; +using Domain.ValueObjects.Pagination; +using Infrastructure.Services.Mediator; +using Microsoft.AspNetCore.Mvc; +using Web.Api.Features.Currencies.CreateCurrenciesCollection; +using Web.Api.Features.Currencies.DeleteCurrenciesCollection; +using Web.Api.Features.Currencies.Dtos; +using Web.Api.Features.Currencies.GetCurrenciesChartData; +using Web.Api.Features.Currencies.GetCurrenciesCollection; +using Web.Api.Setup.Attributes; + +namespace Web.Api.Features.Currencies; + +[ApiController] +[Route("api/currencies")] +public class CurrenciesCollectionController : ControllerBase +{ + private readonly IServiceProvider _serviceProvider; + + public CurrenciesCollectionController( + IServiceProvider serviceProvider) + { + _serviceProvider = serviceProvider; + } + + [HttpGet("chart")] + public Task GetChartData( + [FromQuery] GetCurrenciesChartDataQueryParams queryParams, + CancellationToken cancellationToken) + { + return _serviceProvider.HandleBy( + queryParams ?? new GetCurrenciesChartDataQueryParams(), + cancellationToken); + } + + [HttpGet("")] + [HasAnyRole(Role.Admin)] + public Task> GetAll( + [FromQuery] GetCurrenciesCollectionQueryParams queryParams, + CancellationToken cancellationToken) + { + return _serviceProvider.HandleBy>( + queryParams ?? new GetCurrenciesCollectionQueryParams(), + cancellationToken); + } + + [HttpPost("")] + [HasAnyRole(Role.Admin)] + public Task Create( + [FromBody] CreateCurrenciesCollectionRequest request, + CancellationToken cancellationToken) + { + return _serviceProvider.HandleBy( + request, + cancellationToken); + } + + [HttpDelete("{id:guid}")] + [HasAnyRole(Role.Admin)] + public Task Delete( + [FromRoute] Guid id, + CancellationToken cancellationToken) + { + return _serviceProvider.HandleBy( + new DeleteCurrenciesCollectionRequest { Id = id }, + cancellationToken); + } +} diff --git a/src/Web.Api/Features/Currencies/DeleteCurrenciesCollection/DeleteCurrenciesCollectionHandler.cs b/src/Web.Api/Features/Currencies/DeleteCurrenciesCollection/DeleteCurrenciesCollectionHandler.cs new file mode 100644 index 00000000..8d735190 --- /dev/null +++ b/src/Web.Api/Features/Currencies/DeleteCurrenciesCollection/DeleteCurrenciesCollectionHandler.cs @@ -0,0 +1,30 @@ +using System.Threading; +using System.Threading.Tasks; +using Infrastructure.Database; +using Infrastructure.Services.Mediator; + +namespace Web.Api.Features.Currencies.DeleteCurrenciesCollection; + +public class DeleteCurrenciesCollectionHandler + : IRequestHandler +{ + private readonly DatabaseContext _context; + + public DeleteCurrenciesCollectionHandler(DatabaseContext context) + { + _context = context; + } + + public async Task Handle( + DeleteCurrenciesCollectionRequest request, + CancellationToken cancellationToken) + { + var entity = await _context.CurrencyCollections + .ByIdOrFailAsync(request.Id, cancellationToken: cancellationToken); + + _context.CurrencyCollections.Remove(entity); + await _context.TrySaveChangesAsync(cancellationToken); + + return true; + } +} diff --git a/src/Web.Api/Features/Currencies/DeleteCurrenciesCollection/DeleteCurrenciesCollectionRequest.cs b/src/Web.Api/Features/Currencies/DeleteCurrenciesCollection/DeleteCurrenciesCollectionRequest.cs new file mode 100644 index 00000000..3951a9fe --- /dev/null +++ b/src/Web.Api/Features/Currencies/DeleteCurrenciesCollection/DeleteCurrenciesCollectionRequest.cs @@ -0,0 +1,8 @@ +using System; + +namespace Web.Api.Features.Currencies.DeleteCurrenciesCollection; + +public record DeleteCurrenciesCollectionRequest +{ + public Guid Id { get; init; } +} diff --git a/src/Web.Api/Features/Currencies/Dtos/CurrenciesCollectionDto.cs b/src/Web.Api/Features/Currencies/Dtos/CurrenciesCollectionDto.cs new file mode 100644 index 00000000..5e7fa954 --- /dev/null +++ b/src/Web.Api/Features/Currencies/Dtos/CurrenciesCollectionDto.cs @@ -0,0 +1,38 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Domain.Entities.Currencies; + +namespace Web.Api.Features.Currencies.Dtos; + +public record CurrenciesCollectionDto +{ + public Guid Id { get; init; } + + public List Currencies { get; init; } + + public DateTime CurrencyDate { get; init; } + + public DateTimeOffset CreatedAt { get; init; } + + public DateTimeOffset UpdatedAt { get; init; } + + public CurrenciesCollectionDto() + { + } + + public CurrenciesCollectionDto(CurrenciesCollection entity) + { + Id = entity.Id; + CurrencyDate = entity.CurrencyDate; + CreatedAt = entity.CreatedAt; + UpdatedAt = entity.UpdatedAt; + Currencies = entity.Currencies + .Select(x => new CurrencyItemDto + { + Currency = x.Key, + Value = x.Value, + }) + .ToList(); + } +} diff --git a/src/Web.Api/Features/Currencies/Dtos/CurrencyItemDto.cs b/src/Web.Api/Features/Currencies/Dtos/CurrencyItemDto.cs new file mode 100644 index 00000000..963d340a --- /dev/null +++ b/src/Web.Api/Features/Currencies/Dtos/CurrencyItemDto.cs @@ -0,0 +1,10 @@ +using Domain.Entities.Salaries; + +namespace Web.Api.Features.Currencies.Dtos; + +public record CurrencyItemDto +{ + public Currency Currency { get; init; } + + public double Value { get; init; } +} diff --git a/src/Web.Api/Features/Currencies/Dtos/WeeklyCurrencyChartDataDto.cs b/src/Web.Api/Features/Currencies/Dtos/WeeklyCurrencyChartDataDto.cs new file mode 100644 index 00000000..cc619660 --- /dev/null +++ b/src/Web.Api/Features/Currencies/Dtos/WeeklyCurrencyChartDataDto.cs @@ -0,0 +1,23 @@ +using System; +using System.Collections.Generic; + +namespace Web.Api.Features.Currencies.Dtos; + +public record WeeklyCurrencyChartDataDto +{ + public DateTime WeekStartDate { get; } + + public DateTime WeekEndDate { get; } + + public List AverageCurrencies { get; } + + public WeeklyCurrencyChartDataDto( + DateTime weekStartDate, + DateTime weekEndDate, + List averageCurrencies) + { + WeekStartDate = weekStartDate; + WeekEndDate = weekEndDate; + AverageCurrencies = averageCurrencies; + } +} diff --git a/src/Web.Api/Features/Currencies/GetCurrenciesChartData/GetCurrenciesChartDataHandler.cs b/src/Web.Api/Features/Currencies/GetCurrenciesChartData/GetCurrenciesChartDataHandler.cs new file mode 100644 index 00000000..39b5613c --- /dev/null +++ b/src/Web.Api/Features/Currencies/GetCurrenciesChartData/GetCurrenciesChartDataHandler.cs @@ -0,0 +1,74 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Domain.Entities.Salaries; +using Infrastructure.Database; +using Infrastructure.Services.Mediator; +using Microsoft.EntityFrameworkCore; +using Web.Api.Features.Currencies.Dtos; + +namespace Web.Api.Features.Currencies.GetCurrenciesChartData; + +public class GetCurrenciesChartDataHandler + : IRequestHandler +{ + private readonly DatabaseContext _context; + + public GetCurrenciesChartDataHandler(DatabaseContext context) + { + _context = context; + } + + public async Task Handle( + GetCurrenciesChartDataQueryParams request, + CancellationToken cancellationToken) + { + var toDate = DateTime.UtcNow.Date; + var fromDate = toDate.AddYears(-1); + + var currencyRecords = await _context.CurrencyCollections + .AsNoTracking() + .Where(x => x.CurrencyDate >= fromDate && x.CurrencyDate <= toDate) + .OrderBy(x => x.CurrencyDate) + .ToListAsync(cancellationToken); + + var weeklyData = currencyRecords + .GroupBy(x => GetStartOfWeek(x.CurrencyDate)) + .Select(weekGroup => + { + var weekRecords = weekGroup.ToList(); + var weekStart = weekGroup.Key; + var weekEnd = weekStart.AddDays(6); + + var allCurrencies = weekRecords + .SelectMany(r => r.Currencies) + .GroupBy(kvp => kvp.Key) + .Select(currencyGroup => new CurrencyItemDto + { + Currency = currencyGroup.Key, + Value = currencyGroup.Average(kvp => kvp.Value) + }) + .ToList(); + + return new WeeklyCurrencyChartDataDto( + weekStart, + weekEnd, + allCurrencies); + }) + .OrderBy(x => x.WeekStartDate) + .ToList(); + + return new GetCurrenciesChartDataResponse( + weeklyData, + fromDate, + toDate); + } + + private static DateTime GetStartOfWeek(DateTime date) + { + var diff = (7 + (date.DayOfWeek - DayOfWeek.Monday)) % 7; + return date.AddDays(-1 * diff).Date; + } +} diff --git a/src/Web.Api/Features/Currencies/GetCurrenciesChartData/GetCurrenciesChartDataQueryParams.cs b/src/Web.Api/Features/Currencies/GetCurrenciesChartData/GetCurrenciesChartDataQueryParams.cs new file mode 100644 index 00000000..a8e730c0 --- /dev/null +++ b/src/Web.Api/Features/Currencies/GetCurrenciesChartData/GetCurrenciesChartDataQueryParams.cs @@ -0,0 +1,5 @@ +namespace Web.Api.Features.Currencies.GetCurrenciesChartData; + +public record GetCurrenciesChartDataQueryParams +{ +} diff --git a/src/Web.Api/Features/Currencies/GetCurrenciesChartData/GetCurrenciesChartDataResponse.cs b/src/Web.Api/Features/Currencies/GetCurrenciesChartData/GetCurrenciesChartDataResponse.cs new file mode 100644 index 00000000..7195d79d --- /dev/null +++ b/src/Web.Api/Features/Currencies/GetCurrenciesChartData/GetCurrenciesChartDataResponse.cs @@ -0,0 +1,24 @@ +using System; +using System.Collections.Generic; +using Web.Api.Features.Currencies.Dtos; + +namespace Web.Api.Features.Currencies.GetCurrenciesChartData; + +public record GetCurrenciesChartDataResponse +{ + public List WeeklyData { get; } + + public DateTime FromDate { get; } + + public DateTime ToDate { get; } + + public GetCurrenciesChartDataResponse( + List weeklyData, + DateTime fromDate, + DateTime toDate) + { + WeeklyData = weeklyData; + FromDate = fromDate; + ToDate = toDate; + } +} diff --git a/src/Web.Api/Features/Currencies/GetCurrenciesCollection/GetCurrenciesCollectionHandler.cs b/src/Web.Api/Features/Currencies/GetCurrenciesCollection/GetCurrenciesCollectionHandler.cs new file mode 100644 index 00000000..490ce7d3 --- /dev/null +++ b/src/Web.Api/Features/Currencies/GetCurrenciesCollection/GetCurrenciesCollectionHandler.cs @@ -0,0 +1,34 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Domain.ValueObjects.Pagination; +using Infrastructure.Database; +using Infrastructure.Services.Mediator; +using Microsoft.EntityFrameworkCore; +using Web.Api.Features.Currencies.Dtos; + +namespace Web.Api.Features.Currencies.GetCurrenciesCollection; + +public class GetCurrenciesCollectionHandler + : IRequestHandler> +{ + private readonly DatabaseContext _context; + + public GetCurrenciesCollectionHandler(DatabaseContext context) + { + _context = context; + } + + public async Task> Handle( + GetCurrenciesCollectionQueryParams request, + CancellationToken cancellationToken) + { + return await _context.CurrencyCollections + .AsNoTracking() + .OrderByDescending(x => x.CurrencyDate) + .AsPaginatedAsync( + x => new CurrenciesCollectionDto(x), + request, + cancellationToken); + } +} diff --git a/src/Web.Api/Features/Currencies/GetCurrenciesCollection/GetCurrenciesCollectionQueryParams.cs b/src/Web.Api/Features/Currencies/GetCurrenciesCollection/GetCurrenciesCollectionQueryParams.cs new file mode 100644 index 00000000..2d95b271 --- /dev/null +++ b/src/Web.Api/Features/Currencies/GetCurrenciesCollection/GetCurrenciesCollectionQueryParams.cs @@ -0,0 +1,7 @@ +using Domain.ValueObjects.Pagination; + +namespace Web.Api.Features.Currencies.GetCurrenciesCollection; + +public record GetCurrenciesCollectionQueryParams : PageModel +{ +}