From 37dd5838212a1a0153447b24bdf999297e0de428 Mon Sep 17 00:00:00 2001 From: "maxim.gorbatyuk" Date: Fri, 2 Jan 2026 13:45:44 +0500 Subject: [PATCH 1/5] Added entity for currency --- CLAUDE.md | 277 ++++++++++++ .../Entities/Currencies}/CurrencyContent.cs | 15 +- .../Entities/Currencies/CurrencyEntity.cs | 44 ++ .../Currencies/Contracts/ICurrencyService.cs | 3 +- .../Contracts/KztCurrencyContent.cs | 3 +- .../Currencies/CurrencyService.cs | 7 +- .../Config/CurrencyEntityConfiguration.cs | 23 + .../Database/DatabaseContext.cs | 3 + src/InfrastructureTests/Fakes/Data/FakeXml.cs | 369 --------------- .../Services/CurrencyServiceTests.cs | 427 ++++++++++++++++-- src/TestUtils/Mocks/CurrenciesServiceFake.cs | 1 + src/TestUtils/Services/CurrencyServiceFake.cs | 1 + .../Features/Admin/AdminToolsController.cs | 1 + .../Charts/SalariesChartResponse.cs | 1 + .../GetSalariesChartHandler.cs | 1 + src/techinterview.sln | 1 + 16 files changed, 774 insertions(+), 403 deletions(-) create mode 100644 CLAUDE.md rename src/{Infrastructure/Currencies/Contracts => Domain/Entities/Currencies}/CurrencyContent.cs (85%) create mode 100644 src/Domain/Entities/Currencies/CurrencyEntity.cs create mode 100644 src/Infrastructure/Database/Config/CurrencyEntityConfiguration.cs delete mode 100644 src/InfrastructureTests/Fakes/Data/FakeXml.cs diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..66af85b2 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,277 @@ +# CLAUDE.md - AI Agent Guidelines for Tech.Interviewer Web API + +## Project Overview + +This repository contains the backend API for [techinterview.space](https://techinterview.space) - a monolithic .NET 10 web API application. + +**Repository:** https://github.com/techinterview-space/web-api + +--- + +## Tech Stack + +- .NET 10 +- Entity Framework Core +- PostgreSQL (via Docker) +- Elasticsearch (via Docker) +- LocalStack (AWS services emulation) + +--- + +## Code Style Guidelines + +### Ruleset File + +**ALWAYS follow the linter rules defined in `src/standard.ruleset`.** + +Before writing any code, review the `standard.ruleset` file to understand the project's code analysis rules. This file contains the authoritative style and quality rules for the project. + +```bash +# Location of the ruleset file +src/standard.ruleset +``` + +### Additional Style Guidelines + +In addition to the ruleset, follow these conventions consistent with the existing codebase: + +#### Naming Conventions + +| Element | Style | Example | +|---------|-------|---------| +| Namespaces | PascalCase | `TechInterviewer.Features.Interviews` | +| Classes | PascalCase | `InterviewService` | +| Interfaces | PascalCase with "I" prefix | `IInterviewRepository` | +| Methods | PascalCase | `GetInterviewByIdAsync` | +| Properties | PascalCase | `CreatedAt` | +| Private fields | camelCase with underscore prefix | `_dbContext` | +| Local variables | camelCase | `interviewId` | +| Parameters | camelCase | `cancellationToken` | +| Async methods | PascalCase with "Async" suffix | `CreateInterviewAsync` | + +#### Code Organization + +- Follow existing project structure and patterns +- Use dependency injection for all service dependencies +- Use `async`/`await` for all asynchronous operations +- Follow existing patterns for controllers, services, and repositories + +--- + +## Build Verification + +### Required Action After Code Changes + +**After applying any feature or code modification, always run:** + +```bash +cd src +dotnet build +``` + +This ensures: +- All syntax errors are caught +- All references are resolved +- Code analysis rules from `standard.ruleset` pass +- The solution compiles successfully + +If the build fails, fix the issues before considering the task complete. + +--- + +## Database Migrations + +### ⚠️ IMPORTANT: Migration Restrictions + +**DO NOT generate or modify Entity Framework migrations.** + +- Migrations will be created manually by the user using the provided PowerShell script or EF CLI +- **NEVER** touch or modify `*DatabaseContextModelSnapshot.cs` files (DbContext snapshots) +- **NEVER** create new migration files in the `Migrations` folder +- **NEVER** modify existing migration files + +When making changes to entity models: +1. Modify only the entity classes as needed +2. Inform the user that migrations need to be generated manually +3. Remind the user to run `dotnet ef migrations add ` + +--- + +## Running the Application + +### Prerequisites + +1. Docker Desktop installed and running +2. .NET 10 SDK installed + +### Start Infrastructure + +```bash +# Start required services (database, elasticsearch, localstack) +docker-compose up -d --build database.api elasticsearch localstack +``` + +### Run the Application + +```bash +cd src +dotnet run +``` + +The application will be available at `https://localhost:5001` + +--- + +## Unit Testing Guidelines + +### Project Structure + +**DO NOT create new test projects.** Test projects will be created manually by the user. + +When asked to write tests, add test files to the existing test project structure. + +### Testing Framework + +All unit tests must use **xUnit** library. + +### Test File Requirements + +Each test file **MUST** contain at least one test for the **Happy Path** scenario. + +Additional tests are optional but recommended for: +- Edge cases +- Error handling +- Boundary conditions +- Invalid input validation + +### Test Naming Convention + +Use descriptive test names following this pattern: + +```csharp +[Fact] +public async Task MethodName_StateUnderTest_ExpectedBehavior() +{ + // Arrange + // Act + // Assert +} +``` + +Examples: +```csharp +[Fact] +public async Task GetInterview_WithValidId_ReturnsInterview() + +[Fact] +public async Task GetInterview_WithInvalidId_ThrowsNotFoundException() + +[Theory] +[InlineData(null)] +[InlineData("")] +public async Task CreateInterview_WithInvalidTitle_ThrowsValidationException(string invalidTitle) +``` + +### Test Structure + +Follow the **Arrange-Act-Assert** pattern: + +```csharp +[Fact] +public async Task CreateInterview_WithValidData_ReturnsCreatedInterview() +{ + // Arrange + var request = new CreateInterviewRequest { /* ... */ }; + + // Act + var result = await _sut.CreateInterviewAsync(request, CancellationToken.None); + + // Assert + Assert.NotNull(result); + Assert.Equal(expected, result.Id); +} +``` + +--- + +## Test Framework Utilities + +If the project contains a test framework, **use the existing utilities**: + +### Available Test Utilities (if present) + +#### Mock Services +Look for classes that mock business services: +- Pattern: `Mock*Service`, `*ServiceMock`, `Fake*Service`, `*ServiceFake` +- Location: Usually in a `Mocks/` or `Fakes/` folder within the test project + +#### Entity Fakes +Look for `...Fake` classes that create test database entities: +- Pattern: `*Fake`, `*Builder` +- Purpose: Create valid entity instances for testing +- Example usage: + ```csharp + var interview = new InterviewFake() + .WithUserId(userId) + .WithStatus(InterviewStatus.Completed) + .Please(context); + ``` + +#### In-Memory Database Context +Look for `InMemoryDatabaseContext` or similar classes for database operations in tests: +- Use for integration-style tests that need database operations +- Provides isolated, in-memory database for each test +- Example usage: + ```csharp + using var context = new InMemoryDatabaseContext(); + context.Interviews.Add(interviewEntity); + await context.SaveChangesAsync(); + // ... test repository methods + ``` + +### Discovering Test Utilities + +Before writing tests, check for existing test infrastructure: + +```bash +# Find fake/mock classes +find . -name "*Fake*.cs" -o -name "*Mock*.cs" -o -name "*Builder*.cs" + +# Find in-memory context +find . -name "*InMemory*.cs" -o -name "*TestDbContext*.cs" +``` + +--- + +## Project-Specific Notes + +### This is a Monolith + +This is a single monolithic application - there are no microservices or separate service projects. All features are contained within this single solution. + +### Docker Compose Services + +The project uses Docker Compose for local development infrastructure: +- `database.api` - PostgreSQL database +- `elasticsearch` - Search functionality +- `localstack` - AWS services emulation (S3, etc.) + +### API Documentation + +The API provides RSS feeds and standard REST endpoints. Check the README.md for API documentation. + +--- + +## Summary Checklist + +Before completing any task, verify: + +- [ ] Code follows rules defined in `src/standard.ruleset` +- [ ] `dotnet build` succeeds without errors in the `src` directory +- [ ] No migrations were generated or modified +- [ ] No DbContext snapshot files were touched +- [ ] Unit tests use xUnit +- [ ] Each test file has at least one Happy Path test +- [ ] Existing test framework utilities are used when available +- [ ] No new test projects were created +- [ ] Code follows existing patterns in the codebase \ No newline at end of file diff --git a/src/Infrastructure/Currencies/Contracts/CurrencyContent.cs b/src/Domain/Entities/Currencies/CurrencyContent.cs similarity index 85% rename from src/Infrastructure/Currencies/Contracts/CurrencyContent.cs rename to src/Domain/Entities/Currencies/CurrencyContent.cs index b3571514..33722f98 100644 --- a/src/Infrastructure/Currencies/Contracts/CurrencyContent.cs +++ b/src/Domain/Entities/Currencies/CurrencyContent.cs @@ -1,10 +1,10 @@ -using System.Globalization; +using System; +using System.Globalization; using System.Xml.Linq; using Domain.Entities.Salaries; using Domain.Extensions; -using Domain.Migrations; -namespace Infrastructure.Currencies.Contracts +namespace Domain.Entities.Currencies { public record CurrencyContent { @@ -17,6 +17,15 @@ public CurrencyContent( { } + public CurrencyContent( + CurrencyEntity entity) + : this( + entity.Value, + entity.Currency, + entity.ForDate.Date) + { + } + public CurrencyContent( string value, string currency, diff --git a/src/Domain/Entities/Currencies/CurrencyEntity.cs b/src/Domain/Entities/Currencies/CurrencyEntity.cs new file mode 100644 index 00000000..d3fd74df --- /dev/null +++ b/src/Domain/Entities/Currencies/CurrencyEntity.cs @@ -0,0 +1,44 @@ +using System; +using Domain.Entities.Salaries; + +namespace Domain.Entities.Currencies; + +public class CurrencyEntity : HasDatesBase, IHasIdBase +{ + public Guid Id { get; protected set; } + + public double Value { get; protected set; } + + public Currency Currency { get; protected set; } + + public DateTimeOffset ForDate { get; protected set; } + + public CurrencyEntity( + double value, + Currency currency, + DateTimeOffset forDate) + { + Id = Guid.NewGuid(); + Value = value; + Currency = currency; + ForDate = new DateTimeOffset( + forDate.Year, + forDate.Month, + forDate.Day, + 12, + 0, + 0, + TimeSpan.Zero); + + CreatedAt = UpdatedAt = DateTimeOffset.UtcNow; + } + + public CurrencyContent CreateCurrencyContent() + { + return new CurrencyContent(this); + } + + protected CurrencyEntity() + { + } +} \ No newline at end of file diff --git a/src/Infrastructure/Currencies/Contracts/ICurrencyService.cs b/src/Infrastructure/Currencies/Contracts/ICurrencyService.cs index 1670539e..97fc6d08 100644 --- a/src/Infrastructure/Currencies/Contracts/ICurrencyService.cs +++ b/src/Infrastructure/Currencies/Contracts/ICurrencyService.cs @@ -1,4 +1,5 @@ -using Domain.Entities.Salaries; +using Domain.Entities.Currencies; +using Domain.Entities.Salaries; namespace Infrastructure.Currencies.Contracts { diff --git a/src/Infrastructure/Currencies/Contracts/KztCurrencyContent.cs b/src/Infrastructure/Currencies/Contracts/KztCurrencyContent.cs index a068e779..bc022a4b 100644 --- a/src/Infrastructure/Currencies/Contracts/KztCurrencyContent.cs +++ b/src/Infrastructure/Currencies/Contracts/KztCurrencyContent.cs @@ -1,4 +1,5 @@ -using Domain.Entities.Salaries; +using Domain.Entities.Currencies; +using Domain.Entities.Salaries; namespace Infrastructure.Currencies.Contracts; diff --git a/src/Infrastructure/Currencies/CurrencyService.cs b/src/Infrastructure/Currencies/CurrencyService.cs index 456fd50e..aabaf88a 100644 --- a/src/Infrastructure/Currencies/CurrencyService.cs +++ b/src/Infrastructure/Currencies/CurrencyService.cs @@ -1,7 +1,9 @@ using System.Xml.Linq; +using Domain.Entities.Currencies; using Domain.Entities.Salaries; using Domain.Extensions; using Infrastructure.Currencies.Contracts; +using Infrastructure.Database; using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; @@ -16,17 +18,20 @@ public class CurrencyService : ICurrencyService private readonly IConfiguration _configuration; private readonly IMemoryCache _cache; private readonly ILogger _logger; + private readonly DatabaseContext _context; public CurrencyService( IHttpClientFactory httpClientFactory, IConfiguration configuration, IMemoryCache cache, - ILogger logger) + ILogger logger, + DatabaseContext context) { _httpClientFactory = httpClientFactory; _configuration = configuration; _cache = cache; _logger = logger; + _context = context; } public async Task GetCurrencyOrNullAsync( diff --git a/src/Infrastructure/Database/Config/CurrencyEntityConfiguration.cs b/src/Infrastructure/Database/Config/CurrencyEntityConfiguration.cs new file mode 100644 index 00000000..74bc79f6 --- /dev/null +++ b/src/Infrastructure/Database/Config/CurrencyEntityConfiguration.cs @@ -0,0 +1,23 @@ +using Domain.Entities.Currencies; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Infrastructure.Database.Config; + +public class CurrencyEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure( + EntityTypeBuilder builder) + { + builder.ToTable("CurrencyEntities"); + builder.HasKey(x => x.Id); + + builder + .HasIndex(x => new + { + x.ForDate, + x.Currency, + }) + .IsUnique(); + } +} \ No newline at end of file diff --git a/src/Infrastructure/Database/DatabaseContext.cs b/src/Infrastructure/Database/DatabaseContext.cs index 0a02753e..beb7a737 100644 --- a/src/Infrastructure/Database/DatabaseContext.cs +++ b/src/Infrastructure/Database/DatabaseContext.cs @@ -1,6 +1,7 @@ using Domain.Entities; using Domain.Entities.Companies; using Domain.Entities.CSV; +using Domain.Entities.Currencies; using Domain.Entities.Github; using Domain.Entities.HistoricalRecords; using Domain.Entities.Interviews; @@ -96,6 +97,8 @@ public DatabaseContext(DbContextOptions options) public DbSet SalariesHistoricalDataRecords { get; set; } + public DbSet CurrencyEntities { get; set; } + public async Task SaveAsync( IReadOnlyCollection entities) where TEntity : class, IBaseModel diff --git a/src/InfrastructureTests/Fakes/Data/FakeXml.cs b/src/InfrastructureTests/Fakes/Data/FakeXml.cs deleted file mode 100644 index 6db371e6..00000000 --- a/src/InfrastructureTests/Fakes/Data/FakeXml.cs +++ /dev/null @@ -1,369 +0,0 @@ -namespace InfrastructureTests.Fakes.Data -{ - public static class FakeXml - { - public const string CurrenciesXml = @" - - - Alternate RSS Builder - Official exchange rates of National Bank of Republic Kazakhstan - www.nationalbank.kz - Official exchange rates of National Bank of Republic Kazakhstan - ru - www.nationalbank.kz - - - AUD - 07.06.2024 - 297.05 - 1 - DOWN - -0.85 - - - - AZN - 07.06.2024 - 263.65 - 1 - DOWN - -1.03 - - - - AMD - 07.06.2024 - 11.54 - 10 - DOWN - -0.07 - - - - BYN - 07.06.2024 - 136.83 - 1 - DOWN - -0.53 - - - - BRL - 07.06.2024 - 84.31 - 1 - DOWN - -0.49 - - - - HUF - 07.06.2024 - 12.43 - 10 - - 0.00 - - - - HKD - 07.06.2024 - 57.22 - 1 - DOWN - -0.22 - - - - GEL - 07.06.2024 - 160.18 - 1 - DOWN - -1.49 - - - - DKK - 07.06.2024 - 65.16 - 1 - DOWN - -0.21 - - - - AED - 07.06.2024 - 121.69 - 1 - DOWN - -0.47 - - - - USD - 07.06.2024 - 446.89 - 1 - DOWN - -1.75 - - - - EUR - 07.06.2024 - 485.95 - 1 - DOWN - -1.59 - - - - INR - 07.06.2024 - 5.35 - 1 - DOWN - -0.03 - - - - IRR - 07.06.2024 - 10.6 - 1000 - DOWN - -0.10 - - - - CAD - 07.06.2024 - 326.53 - 1 - DOWN - -1.47 - - - - CNY - 07.06.2024 - 61.69 - 1 - DOWN - -0.22 - - - - KWD - 07.06.2024 - 1458.9 - 1 - DOWN - -5.33 - - - - KGS - 07.06.2024 - 5.12 - 1 - - 0.00 - - - - MYR - 07.06.2024 - 95.25 - 1 - DOWN - -0.29 - - - - MXN - 07.06.2024 - 25.5 - 1 - UP - +0.06 - - - - MDL - 07.06.2024 - 25.54 - 1 - DOWN - -0.04 - - - - NOK - 07.06.2024 - 42.26 - 1 - DOWN - -0.20 - - - - PLN - 07.06.2024 - 112.98 - 1 - DOWN - -0.19 - - - - SAR - 07.06.2024 - 119.16 - 1 - DOWN - -0.46 - - - - RUB - 07.06.2024 - 5.04 - 1 - DOWN - -0.02 - - - - XDR - 07.06.2024 - 592.31 - 1 - DOWN - -2.56 - - - - SGD - 07.06.2024 - 331.89 - 1 - DOWN - -1.08 - - - - TJS - 07.06.2024 - 41.88 - 1 - DOWN - -0.17 - - - - THB - 07.06.2024 - 12.26 - 1 - UP - +0.04 - - - - TRY - 07.06.2024 - 13.87 - 1 - UP - +0.07 - - - - UZS - 07.06.2024 - 3.54 - 100 - DOWN - -0.02 - - - - UAH - 07.06.2024 - 11.14 - 1 - DOWN - -0.05 - - - - GBP - 07.06.2024 - 571.13 - 1 - DOWN - -1.78 - - - - CZK - 07.06.2024 - 19.74 - 1 - DOWN - -0.03 - - - - SEK - 07.06.2024 - 43.03 - 1 - UP - +0.04 - - - - CHF - 07.06.2024 - 501.56 - 1 - DOWN - -1.29 - - - - ZAR - 07.06.2024 - 23.57 - 1 - DOWN - -0.21 - - - - KRW - 07.06.2024 - 32.69 - 100 - - 0.00 - - - - JPY - 07.06.2024 - 2.86 - 1 - DOWN - -0.02 - - - -"; - } -} diff --git a/src/InfrastructureTests/Services/CurrencyServiceTests.cs b/src/InfrastructureTests/Services/CurrencyServiceTests.cs index d6ee3cc0..44c89370 100644 --- a/src/InfrastructureTests/Services/CurrencyServiceTests.cs +++ b/src/InfrastructureTests/Services/CurrencyServiceTests.cs @@ -1,15 +1,16 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Domain.Entities.Salaries; using Domain.Extensions; using Infrastructure.Currencies; -using InfrastructureTests.Fakes.Data; using InfrastructureTests.Mocks; using MemoryCache.Testing.Moq; using Microsoft.Extensions.Logging; using Moq; +using TestUtils.Db; using TestUtils.Fakes; using Xunit; @@ -17,57 +18,427 @@ namespace InfrastructureTests.Services { public class CurrencyServiceTests { + public const string CurrenciesXml = @" + + + Alternate RSS Builder + Official exchange rates of National Bank of Republic Kazakhstan + www.nationalbank.kz + Official exchange rates of National Bank of Republic Kazakhstan + ru + www.nationalbank.kz + + + AUD + 07.06.2024 + 297.05 + 1 + DOWN + -0.85 + + + + AZN + 07.06.2024 + 263.65 + 1 + DOWN + -1.03 + + + + AMD + 07.06.2024 + 11.54 + 10 + DOWN + -0.07 + + + + BYN + 07.06.2024 + 136.83 + 1 + DOWN + -0.53 + + + + BRL + 07.06.2024 + 84.31 + 1 + DOWN + -0.49 + + + + HUF + 07.06.2024 + 12.43 + 10 + + 0.00 + + + + HKD + 07.06.2024 + 57.22 + 1 + DOWN + -0.22 + + + + GEL + 07.06.2024 + 160.18 + 1 + DOWN + -1.49 + + + + DKK + 07.06.2024 + 65.16 + 1 + DOWN + -0.21 + + + + AED + 07.06.2024 + 121.69 + 1 + DOWN + -0.47 + + + + USD + 07.06.2024 + 446.89 + 1 + DOWN + -1.75 + + + + EUR + 07.06.2024 + 485.95 + 1 + DOWN + -1.59 + + + + INR + 07.06.2024 + 5.35 + 1 + DOWN + -0.03 + + + + IRR + 07.06.2024 + 10.6 + 1000 + DOWN + -0.10 + + + + CAD + 07.06.2024 + 326.53 + 1 + DOWN + -1.47 + + + + CNY + 07.06.2024 + 61.69 + 1 + DOWN + -0.22 + + + + KWD + 07.06.2024 + 1458.9 + 1 + DOWN + -5.33 + + + + KGS + 07.06.2024 + 5.12 + 1 + + 0.00 + + + + MYR + 07.06.2024 + 95.25 + 1 + DOWN + -0.29 + + + + MXN + 07.06.2024 + 25.5 + 1 + UP + +0.06 + + + + MDL + 07.06.2024 + 25.54 + 1 + DOWN + -0.04 + + + + NOK + 07.06.2024 + 42.26 + 1 + DOWN + -0.20 + + + + PLN + 07.06.2024 + 112.98 + 1 + DOWN + -0.19 + + + + SAR + 07.06.2024 + 119.16 + 1 + DOWN + -0.46 + + + + RUB + 07.06.2024 + 5.04 + 1 + DOWN + -0.02 + + + + XDR + 07.06.2024 + 592.31 + 1 + DOWN + -2.56 + + + + SGD + 07.06.2024 + 331.89 + 1 + DOWN + -1.08 + + + + TJS + 07.06.2024 + 41.88 + 1 + DOWN + -0.17 + + + + THB + 07.06.2024 + 12.26 + 1 + UP + +0.04 + + + + TRY + 07.06.2024 + 13.87 + 1 + UP + +0.07 + + + + UZS + 07.06.2024 + 3.54 + 100 + DOWN + -0.02 + + + + UAH + 07.06.2024 + 11.14 + 1 + DOWN + -0.05 + + + + GBP + 07.06.2024 + 571.13 + 1 + DOWN + -1.78 + + + + CZK + 07.06.2024 + 19.74 + 1 + DOWN + -0.03 + + + + SEK + 07.06.2024 + 43.03 + 1 + UP + +0.04 + + + + CHF + 07.06.2024 + 501.56 + 1 + DOWN + -1.29 + + + + ZAR + 07.06.2024 + 23.57 + 1 + DOWN + -0.21 + + + + KRW + 07.06.2024 + 32.69 + 100 + + 0.00 + + + + JPY + 07.06.2024 + 2.86 + 1 + DOWN + -0.02 + + + +"; + [Fact] - public async Task GetCurrenciesAsync_HasValidResponeBody_OkAsync() + public async Task GetCurrenciesAsync_HasValidResponseBody_ReturnsOneCurrency() { - // Arrange - var configDic = new Dictionary - { - { "Currencies:Url", "https://currencies.com/rates_all.xml" }, - }; + await using var context = new InMemoryDatabaseContext(); + var memoryConfig = new InMemoryConfig( + new Dictionary + { + { "Currencies:Url", "https://currencies.com/rates_all.xml" }, + }); - var memoryConfig = new InMemoryConfig(configDic); using var mockedCache = Create.MockedMemoryCache(); - var mockHttpClientFactory = new HttpClientFactoryMock(FakeXml.CurrenciesXml); + var mockHttpClientFactory = new HttpClientFactoryMock(CurrenciesXml); var currencyService = new CurrencyService( mockHttpClientFactory.Object, memoryConfig.Value(), mockedCache, - new Mock>().Object); + new Mock>().Object, + context); - // Act - var currencies = await currencyService.GetCurrenciesAsync( - [Currency.USD], - default); + Assert.Equal(0, context.CurrencyEntities.Count()); + + context.ChangeTracker.Clear(); + var currency = await currencyService.GetCurrencyOrNullAsync( + Currency.USD, + CancellationToken.None); // Assert - Assert.Single(currencies); - Assert.Equal(Currency.USD, currencies[0].Currency); - Assert.Equal(446.89, currencies[0].Value); - Assert.Equal(new DateTime(2024, 6, 7).Date, currencies[0].PubDate.Date); + Assert.NotNull(currency); + Assert.Equal(Currency.USD, currency.Currency); + Assert.Equal(446.89, currency.Value); + Assert.Equal(new DateTime(2024, 6, 7).Date, currency.PubDate.Date); } [Fact] - public async Task GetAllCurrenciesAsync_OkAsync() + public async Task GetAllCurrenciesAsync_ReturnsAllCurrencies() { - // Arrange - var configDic = new Dictionary - { - { "Currencies:Url", "https://currencies.com/rates_all.xml" }, - }; + await using var context = new InMemoryDatabaseContext(); + var memoryConfig = new InMemoryConfig( + new Dictionary + { + { "Currencies:Url", "https://currencies.com/rates_all.xml" }, + }); - var memoryConfig = new InMemoryConfig(configDic); using var mockedCache = Create.MockedMemoryCache(); - var mockHttpClientFactory = new HttpClientFactoryMock(FakeXml.CurrenciesXml); + var mockHttpClientFactory = new HttpClientFactoryMock(CurrenciesXml); var currencyService = new CurrencyService( mockHttpClientFactory.Object, memoryConfig.Value(), mockedCache, - new Mock>().Object); + new Mock>().Object, + context); + + Assert.Equal(0, context.CurrencyEntities.Count()); - // Act + context.ChangeTracker.Clear(); var currencies = await currencyService.GetAllCurrenciesAsync(default); // Assert diff --git a/src/TestUtils/Mocks/CurrenciesServiceFake.cs b/src/TestUtils/Mocks/CurrenciesServiceFake.cs index 51d191f8..1bf1c5bd 100644 --- a/src/TestUtils/Mocks/CurrenciesServiceFake.cs +++ b/src/TestUtils/Mocks/CurrenciesServiceFake.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Domain.Entities.Currencies; using Domain.Entities.Salaries; using Infrastructure.Currencies.Contracts; diff --git a/src/TestUtils/Services/CurrencyServiceFake.cs b/src/TestUtils/Services/CurrencyServiceFake.cs index 42a0ee5c..7e88d92c 100644 --- a/src/TestUtils/Services/CurrencyServiceFake.cs +++ b/src/TestUtils/Services/CurrencyServiceFake.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Domain.Entities.Currencies; using Domain.Entities.Salaries; using Infrastructure.Currencies.Contracts; diff --git a/src/Web.Api/Features/Admin/AdminToolsController.cs b/src/Web.Api/Features/Admin/AdminToolsController.cs index f1d1ac9f..459916b9 100644 --- a/src/Web.Api/Features/Admin/AdminToolsController.cs +++ b/src/Web.Api/Features/Admin/AdminToolsController.cs @@ -2,6 +2,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Domain.Entities.Currencies; using Domain.Enums; using Domain.Validation; using Infrastructure.Currencies.Contracts; diff --git a/src/Web.Api/Features/Salaries/GetSalariesChart/Charts/SalariesChartResponse.cs b/src/Web.Api/Features/Salaries/GetSalariesChart/Charts/SalariesChartResponse.cs index 2bee61e9..cc2040ef 100644 --- a/src/Web.Api/Features/Salaries/GetSalariesChart/Charts/SalariesChartResponse.cs +++ b/src/Web.Api/Features/Salaries/GetSalariesChart/Charts/SalariesChartResponse.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Domain.Entities.Currencies; using Domain.Entities.Enums; using Domain.Entities.Salaries; using Domain.Extensions; diff --git a/src/Web.Api/Features/Salaries/GetSalariesChart/GetSalariesChartHandler.cs b/src/Web.Api/Features/Salaries/GetSalariesChart/GetSalariesChartHandler.cs index d07f5bdb..edcd0898 100644 --- a/src/Web.Api/Features/Salaries/GetSalariesChart/GetSalariesChartHandler.cs +++ b/src/Web.Api/Features/Salaries/GetSalariesChart/GetSalariesChartHandler.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Domain.Entities.Currencies; using Domain.Entities.Enums; using Domain.Entities.Salaries; using Domain.Extensions; diff --git a/src/techinterview.sln b/src/techinterview.sln index 40fe5a5b..a57bc7ef 100644 --- a/src/techinterview.sln +++ b/src/techinterview.sln @@ -25,6 +25,7 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Items", "Items", "{0613B82A stylecop.json = stylecop.json standard.ruleset = standard.ruleset .env.example = .env.example + ..\CLAUDE.md = ..\CLAUDE.md EndProjectSection EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Infrastructure", "Infrastructure\Infrastructure.csproj", "{73034202-C61B-4D23-BE4E-0A6391940BB7}" From 0583226ebe9d47c9c10e7f6df553ae605276308b Mon Sep 17 00:00:00 2001 From: "maxim.gorbatyuk" Date: Fri, 2 Jan 2026 15:25:24 +0500 Subject: [PATCH 2/5] Added service --- .../Currencies/CurrenciesCollection.cs | 56 ++ .../Entities/Currencies/CurrencyContent.cs | 9 - .../Entities/Currencies/CurrencyEntity.cs | 44 -- .../Currencies}/KztCurrencyContent.cs | 4 +- .../Currencies/Contracts/ICurrencyService.cs | 6 +- .../Currencies/CurrenciesHttpService.cs | 58 ++ .../Currencies/CurrencyService.cs | 142 ++-- .../Currencies/ICurrenciesHttpService.cs | 9 + ...CurrenciesCollectionEntityConfiguration.cs | 23 + .../Config/CurrencyEntityConfiguration.cs | 23 - .../Database/DatabaseContext.cs | 2 +- .../Mocks/HttpClientFactoryMock.cs | 4 +- .../Services/CurrenciesHttpServiceTests.cs | 415 ++++++++++++ .../Services/CurrencyServiceTests.cs | 617 +++++++----------- .../Fakes/CurrenciesCollectionFake.cs | 43 ++ src/TestUtils/Mocks/CurrenciesServiceFake.cs | 2 +- src/TestUtils/Services/CurrencyServiceFake.cs | 2 +- ...tJob.cs => RefetchServiceCurrenciesJob.cs} | 8 +- src/Web.Api/Setup/ScheduleConfig.cs | 7 +- src/Web.Api/Setup/ServiceRegistration.cs | 1 + 20 files changed, 911 insertions(+), 564 deletions(-) create mode 100644 src/Domain/Entities/Currencies/CurrenciesCollection.cs delete mode 100644 src/Domain/Entities/Currencies/CurrencyEntity.cs rename src/{Infrastructure/Currencies/Contracts => Domain/Entities/Currencies}/KztCurrencyContent.cs (73%) create mode 100644 src/Infrastructure/Currencies/CurrenciesHttpService.cs create mode 100644 src/Infrastructure/Currencies/ICurrenciesHttpService.cs create mode 100644 src/Infrastructure/Database/Config/CurrenciesCollectionEntityConfiguration.cs delete mode 100644 src/Infrastructure/Database/Config/CurrencyEntityConfiguration.cs create mode 100644 src/InfrastructureTests/Services/CurrenciesHttpServiceTests.cs create mode 100644 src/TestUtils/Fakes/CurrenciesCollectionFake.cs rename src/Web.Api/Features/BackgroundJobs/{CurrenciesResetJob.cs => RefetchServiceCurrenciesJob.cs} (64%) diff --git a/src/Domain/Entities/Currencies/CurrenciesCollection.cs b/src/Domain/Entities/Currencies/CurrenciesCollection.cs new file mode 100644 index 00000000..585abad9 --- /dev/null +++ b/src/Domain/Entities/Currencies/CurrenciesCollection.cs @@ -0,0 +1,56 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using Domain.Entities.Salaries; + +namespace Domain.Entities.Currencies; + +public class CurrenciesCollection : HasDatesBase, IHasIdBase +{ + public Guid Id { get; protected set; } + + public Dictionary Currencies { get; protected set; } + + public DateTime CurrencyDate { get; protected set; } + + public CurrenciesCollection( + List currencies) + { + if (currencies.Count == 0) + { + throw new InvalidOperationException("Currencies collection must have at least one currency content."); + } + + Id = Guid.NewGuid(); + CurrencyDate = currencies[0].PubDate; + CreatedAt = UpdatedAt = DateTimeOffset.UtcNow; + + Currencies = currencies + .ToDictionary( + x => x.Currency, + x => x.Value); + } + + public List CreateCurrencies() + { + var result = Currencies + .Select(x => new CurrencyContent( + x.Value, + x.Key, + CurrencyDate)) + .ToList(); + + if (result.All(x => x.Currency is not Currency.KZT)) + { + result.Insert( + 0, + new KztCurrencyContent(CurrencyDate)); + } + + return result; + } + + protected CurrenciesCollection() + { + } +} \ No newline at end of file diff --git a/src/Domain/Entities/Currencies/CurrencyContent.cs b/src/Domain/Entities/Currencies/CurrencyContent.cs index 33722f98..00477667 100644 --- a/src/Domain/Entities/Currencies/CurrencyContent.cs +++ b/src/Domain/Entities/Currencies/CurrencyContent.cs @@ -17,15 +17,6 @@ public CurrencyContent( { } - public CurrencyContent( - CurrencyEntity entity) - : this( - entity.Value, - entity.Currency, - entity.ForDate.Date) - { - } - public CurrencyContent( string value, string currency, diff --git a/src/Domain/Entities/Currencies/CurrencyEntity.cs b/src/Domain/Entities/Currencies/CurrencyEntity.cs deleted file mode 100644 index d3fd74df..00000000 --- a/src/Domain/Entities/Currencies/CurrencyEntity.cs +++ /dev/null @@ -1,44 +0,0 @@ -using System; -using Domain.Entities.Salaries; - -namespace Domain.Entities.Currencies; - -public class CurrencyEntity : HasDatesBase, IHasIdBase -{ - public Guid Id { get; protected set; } - - public double Value { get; protected set; } - - public Currency Currency { get; protected set; } - - public DateTimeOffset ForDate { get; protected set; } - - public CurrencyEntity( - double value, - Currency currency, - DateTimeOffset forDate) - { - Id = Guid.NewGuid(); - Value = value; - Currency = currency; - ForDate = new DateTimeOffset( - forDate.Year, - forDate.Month, - forDate.Day, - 12, - 0, - 0, - TimeSpan.Zero); - - CreatedAt = UpdatedAt = DateTimeOffset.UtcNow; - } - - public CurrencyContent CreateCurrencyContent() - { - return new CurrencyContent(this); - } - - protected CurrencyEntity() - { - } -} \ No newline at end of file diff --git a/src/Infrastructure/Currencies/Contracts/KztCurrencyContent.cs b/src/Domain/Entities/Currencies/KztCurrencyContent.cs similarity index 73% rename from src/Infrastructure/Currencies/Contracts/KztCurrencyContent.cs rename to src/Domain/Entities/Currencies/KztCurrencyContent.cs index bc022a4b..9725a502 100644 --- a/src/Infrastructure/Currencies/Contracts/KztCurrencyContent.cs +++ b/src/Domain/Entities/Currencies/KztCurrencyContent.cs @@ -1,7 +1,7 @@ -using Domain.Entities.Currencies; +using System; using Domain.Entities.Salaries; -namespace Infrastructure.Currencies.Contracts; +namespace Domain.Entities.Currencies; public record KztCurrencyContent : CurrencyContent { diff --git a/src/Infrastructure/Currencies/Contracts/ICurrencyService.cs b/src/Infrastructure/Currencies/Contracts/ICurrencyService.cs index 97fc6d08..7ebe27c0 100644 --- a/src/Infrastructure/Currencies/Contracts/ICurrencyService.cs +++ b/src/Infrastructure/Currencies/Contracts/ICurrencyService.cs @@ -9,14 +9,10 @@ Task GetCurrencyOrNullAsync( Currency currency, CancellationToken cancellationToken); - Task> GetCurrenciesAsync( - List currenciesToGet, - CancellationToken cancellationToken); - Task> GetAllCurrenciesAsync( CancellationToken cancellationToken); - Task ResetCacheAsync( + Task RefetchServiceCurrenciesAsync( CancellationToken cancellationToken); } } diff --git a/src/Infrastructure/Currencies/CurrenciesHttpService.cs b/src/Infrastructure/Currencies/CurrenciesHttpService.cs new file mode 100644 index 00000000..ece3312c --- /dev/null +++ b/src/Infrastructure/Currencies/CurrenciesHttpService.cs @@ -0,0 +1,58 @@ +using System.Xml.Linq; +using Domain.Entities.Currencies; +using Domain.Entities.Salaries; +using Domain.Extensions; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.Logging; + +namespace Infrastructure.Currencies; + +public class CurrenciesHttpService : ICurrenciesHttpService +{ + private readonly IHttpClientFactory _httpClientFactory; + private readonly IConfiguration _configuration; + private readonly ILogger _logger; + + public CurrenciesHttpService( + IHttpClientFactory httpClientFactory, + IConfiguration configuration, + ILogger logger) + { + _httpClientFactory = httpClientFactory; + _configuration = configuration; + _logger = logger; + } + + public async Task> GetCurrenciesAsync( + CancellationToken cancellationToken) + { + var currenciesUrl = _configuration["Currencies:Url"]; + if (string.IsNullOrEmpty(currenciesUrl)) + { + throw new InvalidOperationException("Currencies url is not set"); + } + + try + { + using var client = _httpClientFactory.CreateClient(); + var xmlContent = await client.GetStringAsync(currenciesUrl, cancellationToken); + var xdoc = XDocument.Parse(xmlContent); + + var currenciesToSave = EnumHelper.Values(true); + return xdoc.Descendants("item") + .Select(x => new CurrencyContent(x)) + .Where(x => currenciesToSave.Contains(x.Currency)) + .ToList(); + } + catch (Exception e) + { + _logger.LogError( + e, + "Error while getting currencies from {Url}. Message {Message}", + currenciesUrl, + e.Message); + + return new List(0); + } + } +} \ No newline at end of file diff --git a/src/Infrastructure/Currencies/CurrencyService.cs b/src/Infrastructure/Currencies/CurrencyService.cs index aabaf88a..4c80a8fa 100644 --- a/src/Infrastructure/Currencies/CurrencyService.cs +++ b/src/Infrastructure/Currencies/CurrencyService.cs @@ -1,11 +1,9 @@ -using System.Xml.Linq; -using Domain.Entities.Currencies; +using Domain.Entities.Currencies; using Domain.Entities.Salaries; -using Domain.Extensions; using Infrastructure.Currencies.Contracts; using Infrastructure.Database; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Caching.Memory; -using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; namespace Infrastructure.Currencies @@ -14,21 +12,18 @@ public class CurrencyService : ICurrencyService { private const string CacheKey = "CurrencyService__AllCurrencies"; - private readonly IHttpClientFactory _httpClientFactory; - private readonly IConfiguration _configuration; + private readonly ICurrenciesHttpService _currenciesHttpService; private readonly IMemoryCache _cache; private readonly ILogger _logger; private readonly DatabaseContext _context; public CurrencyService( - IHttpClientFactory httpClientFactory, - IConfiguration configuration, + ICurrenciesHttpService currenciesHttpService, IMemoryCache cache, ILogger logger, DatabaseContext context) { - _httpClientFactory = httpClientFactory; - _configuration = configuration; + _currenciesHttpService = currenciesHttpService; _cache = cache; _logger = logger; _context = context; @@ -38,32 +33,9 @@ public async Task GetCurrencyOrNullAsync( Currency currency, CancellationToken cancellationToken) { - return (await GetCurrenciesAsync( - [currency], - cancellationToken)) - .FirstOrDefault(); - } - - public async Task> GetCurrenciesAsync( - List currenciesToGet, - CancellationToken cancellationToken) - { - if (currenciesToGet == null || !currenciesToGet.Any()) - { - return new List(); - } - - var currencies = await _cache.GetOrCreateAsync( - CacheKey, - async entry => - { - entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(1); - return await GetCurrenciesInternalAsync(cancellationToken); - }); - - return currencies - .Where(x => currenciesToGet.Contains(x.Currency)) - .ToList(); + var allCurrencies = await GetAllCurrenciesAsync(cancellationToken); + return allCurrencies + .FirstOrDefault(x => x.Currency == currency); } public async Task> GetAllCurrenciesAsync( @@ -73,66 +45,84 @@ public async Task> GetAllCurrenciesAsync( CacheKey, async entry => { - entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromDays(1); - return await GetCurrenciesInternalAsync(cancellationToken); + entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(6); + + var latestCurrenciesCollection = await _context.CurrencyCollections + .OrderByDescending(x => x.CurrencyDate) + .FirstOrDefaultAsync(cancellationToken); + + if (latestCurrenciesCollection is not null) + { + return latestCurrenciesCollection.CreateCurrencies(); + } + + var recreatedCurrenciesCollection = await RecreateCurrenciesCollectionOrNullAsync( + DateTime.UtcNow, + cancellationToken); + + if (recreatedCurrenciesCollection is not null) + { + return recreatedCurrenciesCollection.CreateCurrencies(); + } + + return CreateDefaultCollection(); }); } - public async Task ResetCacheAsync( + public async Task RefetchServiceCurrenciesAsync( CancellationToken cancellationToken) { + await RecreateCurrenciesCollectionOrNullAsync( + DateTime.UtcNow, + cancellationToken); + if (_cache.TryGetValue(CacheKey, out _)) { _cache.Remove(CacheKey); } - - await GetAllCurrenciesAsync(cancellationToken); } - private async Task> GetCurrenciesInternalAsync( + private async Task RecreateCurrenciesCollectionOrNullAsync( + DateTime forDate, CancellationToken cancellationToken) { - var currenciesUrl = _configuration["Currencies:Url"]; - if (string.IsNullOrEmpty(currenciesUrl)) - { - throw new InvalidOperationException("Currencies url is not set"); - } + var dateForQuery = forDate.Date; + var existingEntity = await _context.CurrencyCollections + .FirstOrDefaultAsync( + x => x.CurrencyDate == dateForQuery, + cancellationToken); - try + if (existingEntity is null) { - using var client = _httpClientFactory.CreateClient(); - var xmlContent = await client.GetStringAsync(currenciesUrl, cancellationToken); - var xdoc = XDocument.Parse(xmlContent); - - var currenciesToSave = EnumHelper.Values(true); - var items = xdoc.Descendants("item") - .Select(x => new CurrencyContent(x)) - .Where(x => currenciesToSave.Contains(x.Currency)) - .ToList(); - - if (items.All(x => x.Currency is not Currency.KZT)) + var currencies = await _currenciesHttpService.GetCurrenciesAsync(cancellationToken); + if (currencies.Count == 0) { - var pubDate = items.FirstOrDefault()?.PubDate ?? DateTime.UtcNow; - items.Insert( - 0, - new KztCurrencyContent(pubDate)); + return null; } - return items; - } - catch (Exception e) - { - _logger.LogError( - e, - "Error while getting currencies from {Url}. Message {Message}", - currenciesUrl, - e.Message); + var newEntity = new CurrenciesCollection(currencies); - return new List - { - new KztCurrencyContent(DateTime.UtcNow), - }; + _context.CurrencyCollections.Add(newEntity); + await _context.SaveChangesAsync(cancellationToken); + + return newEntity; } + + _logger.LogInformation( + "There is an existing record for date {Date} ({Currencies}) [ID:{RecordId}]", + existingEntity.CurrencyDate.ToString("O"), + string.Join(", ", existingEntity.Currencies.Select(x => x.Key)), + existingEntity.Id); + + return existingEntity; + } + + private List CreateDefaultCollection() + { + return new List + { + new KztCurrencyContent(DateTime.UtcNow), + }; } } } diff --git a/src/Infrastructure/Currencies/ICurrenciesHttpService.cs b/src/Infrastructure/Currencies/ICurrenciesHttpService.cs new file mode 100644 index 00000000..c3bbe4d1 --- /dev/null +++ b/src/Infrastructure/Currencies/ICurrenciesHttpService.cs @@ -0,0 +1,9 @@ +using Domain.Entities.Currencies; + +namespace Infrastructure.Currencies; + +public interface ICurrenciesHttpService +{ + Task> GetCurrenciesAsync( + CancellationToken cancellationToken); +} \ No newline at end of file diff --git a/src/Infrastructure/Database/Config/CurrenciesCollectionEntityConfiguration.cs b/src/Infrastructure/Database/Config/CurrenciesCollectionEntityConfiguration.cs new file mode 100644 index 00000000..a0149530 --- /dev/null +++ b/src/Infrastructure/Database/Config/CurrenciesCollectionEntityConfiguration.cs @@ -0,0 +1,23 @@ +using Domain.Entities.Currencies; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Infrastructure.Database.Config; + +public class CurrenciesCollectionEntityConfiguration : IEntityTypeConfiguration +{ + public void Configure( + EntityTypeBuilder builder) + { + builder.ToTable("CurrenciesCollections"); + builder.HasKey(x => x.Id); + + builder + .Property(x => x.Currencies) + .HasJsonConversion(); + + builder + .HasIndex(x => x.CurrencyDate) + .IsUnique(); + } +} \ No newline at end of file diff --git a/src/Infrastructure/Database/Config/CurrencyEntityConfiguration.cs b/src/Infrastructure/Database/Config/CurrencyEntityConfiguration.cs deleted file mode 100644 index 74bc79f6..00000000 --- a/src/Infrastructure/Database/Config/CurrencyEntityConfiguration.cs +++ /dev/null @@ -1,23 +0,0 @@ -using Domain.Entities.Currencies; -using Microsoft.EntityFrameworkCore; -using Microsoft.EntityFrameworkCore.Metadata.Builders; - -namespace Infrastructure.Database.Config; - -public class CurrencyEntityConfiguration : IEntityTypeConfiguration -{ - public void Configure( - EntityTypeBuilder builder) - { - builder.ToTable("CurrencyEntities"); - builder.HasKey(x => x.Id); - - builder - .HasIndex(x => new - { - x.ForDate, - x.Currency, - }) - .IsUnique(); - } -} \ No newline at end of file diff --git a/src/Infrastructure/Database/DatabaseContext.cs b/src/Infrastructure/Database/DatabaseContext.cs index beb7a737..a29895e1 100644 --- a/src/Infrastructure/Database/DatabaseContext.cs +++ b/src/Infrastructure/Database/DatabaseContext.cs @@ -97,7 +97,7 @@ public DatabaseContext(DbContextOptions options) public DbSet SalariesHistoricalDataRecords { get; set; } - public DbSet CurrencyEntities { get; set; } + public DbSet CurrencyCollections { get; set; } public async Task SaveAsync( IReadOnlyCollection entities) diff --git a/src/InfrastructureTests/Mocks/HttpClientFactoryMock.cs b/src/InfrastructureTests/Mocks/HttpClientFactoryMock.cs index 9d865945..3ceb5ac8 100644 --- a/src/InfrastructureTests/Mocks/HttpClientFactoryMock.cs +++ b/src/InfrastructureTests/Mocks/HttpClientFactoryMock.cs @@ -25,10 +25,10 @@ public HttpClientFactoryMock( Content = new StringContent(respData) }); - var httpClient = new HttpClient(mockHandler.Object); + var client = new HttpClient(mockHandler.Object); Setup(factory => factory.CreateClient(It.IsAny())) - .Returns(httpClient); + .Returns(client); } } } diff --git a/src/InfrastructureTests/Services/CurrenciesHttpServiceTests.cs b/src/InfrastructureTests/Services/CurrenciesHttpServiceTests.cs new file mode 100644 index 00000000..147b07dc --- /dev/null +++ b/src/InfrastructureTests/Services/CurrenciesHttpServiceTests.cs @@ -0,0 +1,415 @@ +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using Domain.Entities.Salaries; +using Domain.Extensions; +using Infrastructure.Currencies; +using InfrastructureTests.Mocks; +using MemoryCache.Testing.Moq; +using Microsoft.Extensions.Logging; +using Moq; +using TestUtils.Fakes; +using Xunit; + +namespace InfrastructureTests.Services; + +public class CurrenciesHttpServiceTests +{ + public const string CurrenciesXml = @" + + + Alternate RSS Builder + Official exchange rates of National Bank of Republic Kazakhstan + www.nationalbank.kz + Official exchange rates of National Bank of Republic Kazakhstan + ru + www.nationalbank.kz + + + AUD + 07.06.2024 + 297.05 + 1 + DOWN + -0.85 + + + + AZN + 07.06.2024 + 263.65 + 1 + DOWN + -1.03 + + + + AMD + 07.06.2024 + 11.54 + 10 + DOWN + -0.07 + + + + BYN + 07.06.2024 + 136.83 + 1 + DOWN + -0.53 + + + + BRL + 07.06.2024 + 84.31 + 1 + DOWN + -0.49 + + + + HUF + 07.06.2024 + 12.43 + 10 + + 0.00 + + + + HKD + 07.06.2024 + 57.22 + 1 + DOWN + -0.22 + + + + GEL + 07.06.2024 + 160.18 + 1 + DOWN + -1.49 + + + + DKK + 07.06.2024 + 65.16 + 1 + DOWN + -0.21 + + + + AED + 07.06.2024 + 121.69 + 1 + DOWN + -0.47 + + + + USD + 07.06.2024 + 446.89 + 1 + DOWN + -1.75 + + + + EUR + 07.06.2024 + 485.95 + 1 + DOWN + -1.59 + + + + INR + 07.06.2024 + 5.35 + 1 + DOWN + -0.03 + + + + IRR + 07.06.2024 + 10.6 + 1000 + DOWN + -0.10 + + + + CAD + 07.06.2024 + 326.53 + 1 + DOWN + -1.47 + + + + CNY + 07.06.2024 + 61.69 + 1 + DOWN + -0.22 + + + + KWD + 07.06.2024 + 1458.9 + 1 + DOWN + -5.33 + + + + KGS + 07.06.2024 + 5.12 + 1 + + 0.00 + + + + MYR + 07.06.2024 + 95.25 + 1 + DOWN + -0.29 + + + + MXN + 07.06.2024 + 25.5 + 1 + UP + +0.06 + + + + MDL + 07.06.2024 + 25.54 + 1 + DOWN + -0.04 + + + + NOK + 07.06.2024 + 42.26 + 1 + DOWN + -0.20 + + + + PLN + 07.06.2024 + 112.98 + 1 + DOWN + -0.19 + + + + SAR + 07.06.2024 + 119.16 + 1 + DOWN + -0.46 + + + + RUB + 07.06.2024 + 5.04 + 1 + DOWN + -0.02 + + + + XDR + 07.06.2024 + 592.31 + 1 + DOWN + -2.56 + + + + SGD + 07.06.2024 + 331.89 + 1 + DOWN + -1.08 + + + + TJS + 07.06.2024 + 41.88 + 1 + DOWN + -0.17 + + + + THB + 07.06.2024 + 12.26 + 1 + UP + +0.04 + + + + TRY + 07.06.2024 + 13.87 + 1 + UP + +0.07 + + + + UZS + 07.06.2024 + 3.54 + 100 + DOWN + -0.02 + + + + UAH + 07.06.2024 + 11.14 + 1 + DOWN + -0.05 + + + + GBP + 07.06.2024 + 571.13 + 1 + DOWN + -1.78 + + + + CZK + 07.06.2024 + 19.74 + 1 + DOWN + -0.03 + + + + SEK + 07.06.2024 + 43.03 + 1 + UP + +0.04 + + + + CHF + 07.06.2024 + 501.56 + 1 + DOWN + -1.29 + + + + ZAR + 07.06.2024 + 23.57 + 1 + DOWN + -0.21 + + + + KRW + 07.06.2024 + 32.69 + 100 + + 0.00 + + + + JPY + 07.06.2024 + 2.86 + 1 + DOWN + -0.02 + + + +"; + + [Fact] + public async Task GetCurrenciesAsync_ValidResponse_ReturnsAllCurrencies() + { + var memoryConfig = new InMemoryConfig( + new Dictionary + { + { "Currencies:Url", "https://currencies.com/rates_all.xml" }, + }); + + using var mockedCache = Create.MockedMemoryCache(); + var mockHttpClientFactory = new HttpClientFactoryMock(CurrenciesXml); + + var currencyService = new CurrenciesHttpService( + mockHttpClientFactory.Object, + memoryConfig.Value(), + new Mock>().Object); + + var currencies = await currencyService.GetCurrenciesAsync(default); + + // Assert + var existingCurrencies = EnumHelper + .Values(true) + .Where(x => x != Currency.KZT) + .ToList(); + + Assert.NotEmpty(existingCurrencies); + + Assert.Equal(existingCurrencies.Count, currencies.Count); + foreach (var currency in currencies) + { + Assert.Contains(currency.Currency, existingCurrencies); + } + } +} \ No newline at end of file diff --git a/src/InfrastructureTests/Services/CurrencyServiceTests.cs b/src/InfrastructureTests/Services/CurrencyServiceTests.cs index 44c89370..9dab0045 100644 --- a/src/InfrastructureTests/Services/CurrencyServiceTests.cs +++ b/src/InfrastructureTests/Services/CurrencyServiceTests.cs @@ -4,9 +4,7 @@ using System.Threading; using System.Threading.Tasks; using Domain.Entities.Salaries; -using Domain.Extensions; using Infrastructure.Currencies; -using InfrastructureTests.Mocks; using MemoryCache.Testing.Moq; using Microsoft.Extensions.Logging; using Moq; @@ -18,391 +16,36 @@ namespace InfrastructureTests.Services { public class CurrencyServiceTests { - public const string CurrenciesXml = @" - - - Alternate RSS Builder - Official exchange rates of National Bank of Republic Kazakhstan - www.nationalbank.kz - Official exchange rates of National Bank of Republic Kazakhstan - ru - www.nationalbank.kz - - - AUD - 07.06.2024 - 297.05 - 1 - DOWN - -0.85 - - - - AZN - 07.06.2024 - 263.65 - 1 - DOWN - -1.03 - - - - AMD - 07.06.2024 - 11.54 - 10 - DOWN - -0.07 - - - - BYN - 07.06.2024 - 136.83 - 1 - DOWN - -0.53 - - - - BRL - 07.06.2024 - 84.31 - 1 - DOWN - -0.49 - - - - HUF - 07.06.2024 - 12.43 - 10 - - 0.00 - - - - HKD - 07.06.2024 - 57.22 - 1 - DOWN - -0.22 - - - - GEL - 07.06.2024 - 160.18 - 1 - DOWN - -1.49 - - - - DKK - 07.06.2024 - 65.16 - 1 - DOWN - -0.21 - - - - AED - 07.06.2024 - 121.69 - 1 - DOWN - -0.47 - - - - USD - 07.06.2024 - 446.89 - 1 - DOWN - -1.75 - - - - EUR - 07.06.2024 - 485.95 - 1 - DOWN - -1.59 - - - - INR - 07.06.2024 - 5.35 - 1 - DOWN - -0.03 - - - - IRR - 07.06.2024 - 10.6 - 1000 - DOWN - -0.10 - - - - CAD - 07.06.2024 - 326.53 - 1 - DOWN - -1.47 - - - - CNY - 07.06.2024 - 61.69 - 1 - DOWN - -0.22 - - - - KWD - 07.06.2024 - 1458.9 - 1 - DOWN - -5.33 - - - - KGS - 07.06.2024 - 5.12 - 1 - - 0.00 - - - - MYR - 07.06.2024 - 95.25 - 1 - DOWN - -0.29 - - - - MXN - 07.06.2024 - 25.5 - 1 - UP - +0.06 - - - - MDL - 07.06.2024 - 25.54 - 1 - DOWN - -0.04 - - - - NOK - 07.06.2024 - 42.26 - 1 - DOWN - -0.20 - - - - PLN - 07.06.2024 - 112.98 - 1 - DOWN - -0.19 - - - - SAR - 07.06.2024 - 119.16 - 1 - DOWN - -0.46 - - - - RUB - 07.06.2024 - 5.04 - 1 - DOWN - -0.02 - - - - XDR - 07.06.2024 - 592.31 - 1 - DOWN - -2.56 - - - - SGD - 07.06.2024 - 331.89 - 1 - DOWN - -1.08 - - - - TJS - 07.06.2024 - 41.88 - 1 - DOWN - -0.17 - - - - THB - 07.06.2024 - 12.26 - 1 - UP - +0.04 - - - - TRY - 07.06.2024 - 13.87 - 1 - UP - +0.07 - - - - UZS - 07.06.2024 - 3.54 - 100 - DOWN - -0.02 - - - - UAH - 07.06.2024 - 11.14 - 1 - DOWN - -0.05 - - - - GBP - 07.06.2024 - 571.13 - 1 - DOWN - -1.78 - - - - CZK - 07.06.2024 - 19.74 - 1 - DOWN - -0.03 - - - - SEK - 07.06.2024 - 43.03 - 1 - UP - +0.04 - - - - CHF - 07.06.2024 - 501.56 - 1 - DOWN - -1.29 - - - - ZAR - 07.06.2024 - 23.57 - 1 - DOWN - -0.21 - - - - KRW - 07.06.2024 - 32.69 - 100 - - 0.00 - - - - JPY - 07.06.2024 - 2.86 - 1 - DOWN - -0.02 - - - -"; - [Fact] - public async Task GetCurrenciesAsync_HasValidResponseBody_ReturnsOneCurrency() + public async Task GetCurrenciesAsync_NoSavedDatabaseEntities_ReturnsOneCurrency() { await using var context = new InMemoryDatabaseContext(); - var memoryConfig = new InMemoryConfig( - new Dictionary + + var now = DateTime.UtcNow; + var currenciesHttpService = new Mock(); + currenciesHttpService + .Setup(x => x.GetCurrenciesAsync(It.IsAny())) + .ReturnsAsync(new List { - { "Currencies:Url", "https://currencies.com/rates_all.xml" }, + new ( + 400, + Currency.USD, + now), + new ( + 500, + Currency.EUR, + now), }); using var mockedCache = Create.MockedMemoryCache(); - var mockHttpClientFactory = new HttpClientFactoryMock(CurrenciesXml); var currencyService = new CurrencyService( - mockHttpClientFactory.Object, - memoryConfig.Value(), + currenciesHttpService.Object, mockedCache, new Mock>().Object, context); - Assert.Equal(0, context.CurrencyEntities.Count()); + Assert.Equal(0, context.CurrencyCollections.Count()); context.ChangeTracker.Clear(); var currency = await currencyService.GetCurrencyOrNullAsync( @@ -412,47 +55,235 @@ public async Task GetCurrenciesAsync_HasValidResponseBody_ReturnsOneCurrency() // Assert Assert.NotNull(currency); Assert.Equal(Currency.USD, currency.Currency); - Assert.Equal(446.89, currency.Value); - Assert.Equal(new DateTime(2024, 6, 7).Date, currency.PubDate.Date); + Assert.Equal(400, currency.Value); + Assert.Equal(now, currency.PubDate); + + Assert.Equal(1, context.CurrencyCollections.Count()); + currenciesHttpService + .Verify( + x => x.GetCurrenciesAsync(It.IsAny()), + Times.Once); } [Fact] - public async Task GetAllCurrenciesAsync_ReturnsAllCurrencies() + public async Task GetAllCurrenciesAsync_NoSavedDatabaseRecord_ReturnsAllCurrencies() { await using var context = new InMemoryDatabaseContext(); - var memoryConfig = new InMemoryConfig( - new Dictionary + var now = DateTime.UtcNow; + var currenciesHttpService = new Mock(); + currenciesHttpService + .Setup(x => x.GetCurrenciesAsync(It.IsAny())) + .ReturnsAsync(new List { - { "Currencies:Url", "https://currencies.com/rates_all.xml" }, + new ( + 400, + Currency.USD, + now), + new ( + 500, + Currency.EUR, + now), }); using var mockedCache = Create.MockedMemoryCache(); - var mockHttpClientFactory = new HttpClientFactoryMock(CurrenciesXml); var currencyService = new CurrencyService( - mockHttpClientFactory.Object, - memoryConfig.Value(), + currenciesHttpService.Object, mockedCache, new Mock>().Object, context); - Assert.Equal(0, context.CurrencyEntities.Count()); + var collection1 = new CurrenciesCollectionFake(DateTime.UtcNow.AddDays(-2)).Please(context); + var collection2 = new CurrenciesCollectionFake(DateTime.UtcNow.AddDays(-3)).Please(context); + var collection3 = new CurrenciesCollectionFake(DateTime.UtcNow.AddDays(-4)).Please(context); + + Assert.Equal(3, context.CurrencyCollections.Count()); context.ChangeTracker.Clear(); var currencies = await currencyService.GetAllCurrenciesAsync(default); + Assert.NotNull(currencies); + Assert.Equal(3, currencies.Count); + Assert.Contains(currencies, x => x.Currency == Currency.USD); + Assert.Contains(currencies, x => x.Currency == Currency.EUR); + Assert.Contains(currencies, x => x.Currency == Currency.KZT); + + Assert.Equal(collection1.CurrencyDate, currencies[0].PubDate); + Assert.NotEqual(collection2.CurrencyDate, currencies[0].PubDate); + Assert.NotEqual(collection3.CurrencyDate, currencies[0].PubDate); + + // Assert + currenciesHttpService + .Verify( + x => x.GetCurrenciesAsync(It.IsAny()), + Times.Never); + } + + [Fact] + public async Task RefetchServiceCurrenciesAsync_HttpServiceReturnsRecords_NewRecordCreatedInDatabase() + { + // Arrange + await using var context = new InMemoryDatabaseContext(); + var now = DateTime.UtcNow; + + var currenciesHttpService = new Mock(); + currenciesHttpService + .Setup(x => x.GetCurrenciesAsync(It.IsAny())) + .ReturnsAsync(new List + { + new (400, Currency.USD, now), + new (500, Currency.EUR, now), + }); + + using var mockedCache = Create.MockedMemoryCache(); + + var currencyService = new CurrencyService( + currenciesHttpService.Object, + mockedCache, + new Mock>().Object, + context); + + Assert.Equal(0, context.CurrencyCollections.Count()); + + // Act + await currencyService.RefetchServiceCurrenciesAsync(CancellationToken.None); + + // Assert + Assert.Equal(1, context.CurrencyCollections.Count()); + var savedCollection = context.CurrencyCollections.First(); + Assert.Equal(now.Date, savedCollection.CurrencyDate.Date); + Assert.Equal(2, savedCollection.Currencies.Count); + + currenciesHttpService + .Verify( + x => x.GetCurrenciesAsync(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task RefetchServiceCurrenciesAsync_HttpServiceReturnsNoRecords_NoRecordCreatedInDatabase() + { + // Arrange + await using var context = new InMemoryDatabaseContext(); + + var currenciesHttpService = new Mock(); + currenciesHttpService + .Setup(x => x.GetCurrenciesAsync(It.IsAny())) + .ReturnsAsync(new List()); + + using var mockedCache = Create.MockedMemoryCache(); + + var currencyService = new CurrencyService( + currenciesHttpService.Object, + mockedCache, + new Mock>().Object, + context); + + Assert.Equal(0, context.CurrencyCollections.Count()); + + // Act + await currencyService.RefetchServiceCurrenciesAsync(CancellationToken.None); + + // Assert + Assert.Equal(0, context.CurrencyCollections.Count()); + + currenciesHttpService + .Verify( + x => x.GetCurrenciesAsync(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task RefetchServiceCurrenciesAsync_SomeDbRecordsForPrevousDate_NewRecordCreatedInDatabase() + { + // Arrange + await using var context = new InMemoryDatabaseContext(); + var now = DateTime.UtcNow; + + var record1 = new CurrenciesCollectionFake(now.Date.AddDays(-1)).Please(context); + var record2 = new CurrenciesCollectionFake(now.Date.AddDays(-2)).Please(context); + + var currenciesHttpService = new Mock(); + currenciesHttpService + .Setup(x => x.GetCurrenciesAsync(It.IsAny())) + .ReturnsAsync(new List + { + new (400, Currency.USD, now), + new (500, Currency.EUR, now), + }); + + using var mockedCache = Create.MockedMemoryCache(); + + var currencyService = new CurrencyService( + currenciesHttpService.Object, + mockedCache, + new Mock>().Object, + context); + + Assert.Equal(2, context.CurrencyCollections.Count()); + + // Act + await currencyService.RefetchServiceCurrenciesAsync(CancellationToken.None); + // Assert - var existingCurrencies = EnumHelper - .Values(true) - .ToList(); + Assert.Equal(3, context.CurrencyCollections.Count()); + var savedCollection = context.CurrencyCollections.Last(); + + Assert.NotEqual(record1.CurrencyDate, savedCollection.CurrencyDate); + Assert.NotEqual(record2.CurrencyDate, savedCollection.CurrencyDate); + + Assert.Equal(now.Date, savedCollection.CurrencyDate.Date); + Assert.Equal(2, savedCollection.Currencies.Count); - Assert.NotEmpty(existingCurrencies); + currenciesHttpService + .Verify( + x => x.GetCurrenciesAsync(It.IsAny()), + Times.Once); + } + + [Fact] + public async Task RefetchServiceCurrenciesAsync_ExistingRecordForSameDate_NoNewRecordCreated() + { + // Arrange + await using var context = new InMemoryDatabaseContext(); + var now = DateTime.UtcNow; + + var existingCollection = new CurrenciesCollectionFake(now.Date).Please(context); + + var currenciesHttpService = new Mock(); + currenciesHttpService + .Setup(x => x.GetCurrenciesAsync(It.IsAny())) + .ReturnsAsync(new List + { + new (400, Currency.USD, now), + new (500, Currency.EUR, now), + }); + + using var mockedCache = Create.MockedMemoryCache(); + + var currencyService = new CurrencyService( + currenciesHttpService.Object, + mockedCache, + new Mock>().Object, + context); + + Assert.Equal(1, context.CurrencyCollections.Count()); + + context.ChangeTracker.Clear(); + + // Act + await currencyService.RefetchServiceCurrenciesAsync(CancellationToken.None); + + // Assert + Assert.Equal(1, context.CurrencyCollections.Count()); + var savedCollection = context.CurrencyCollections.First(); + Assert.Equal(existingCollection.Id, savedCollection.Id); + Assert.Equal(now.Date, savedCollection.CurrencyDate); - Assert.Equal(existingCurrencies.Count, currencies.Count); - foreach (var currency in currencies) - { - Assert.Contains(currency.Currency, existingCurrencies); - } + currenciesHttpService + .Verify( + x => x.GetCurrenciesAsync(It.IsAny()), + Times.Never); } } } diff --git a/src/TestUtils/Fakes/CurrenciesCollectionFake.cs b/src/TestUtils/Fakes/CurrenciesCollectionFake.cs new file mode 100644 index 00000000..4eefd4ef --- /dev/null +++ b/src/TestUtils/Fakes/CurrenciesCollectionFake.cs @@ -0,0 +1,43 @@ +using System; +using System.Collections.Generic; +using Domain.Entities.Currencies; +using Domain.Entities.Salaries; +using TestUtils.Db; + +namespace TestUtils.Fakes; + +public class CurrenciesCollectionFake : CurrenciesCollection +{ + public CurrenciesCollectionFake( + DateTime currencyDate, + List currencies = null) + : base( + currencies ?? CreateDefaultCurrencies(currencyDate)) + { + } + + public static List CreateDefaultCurrencies( + DateTime currencyDate) + { + return new List() + { + new CurrencyContent( + 400, + Currency.USD, + currencyDate), + new CurrencyContent( + 500, + Currency.EUR, + currencyDate), + new KztCurrencyContent(currencyDate), + }; + } + + public CurrenciesCollection Please( + InMemoryDatabaseContext context) + { + var entry = context.Add((CurrenciesCollection)this); + context.SaveChanges(); + return entry.Entity; + } +} \ No newline at end of file diff --git a/src/TestUtils/Mocks/CurrenciesServiceFake.cs b/src/TestUtils/Mocks/CurrenciesServiceFake.cs index 1bf1c5bd..bdc4165f 100644 --- a/src/TestUtils/Mocks/CurrenciesServiceFake.cs +++ b/src/TestUtils/Mocks/CurrenciesServiceFake.cs @@ -62,7 +62,7 @@ public Task> GetAllCurrenciesAsync( return Task.FromResult(_currencies); } - public Task ResetCacheAsync( + public Task RefetchServiceCurrenciesAsync( CancellationToken cancellationToken) { return Task.CompletedTask; diff --git a/src/TestUtils/Services/CurrencyServiceFake.cs b/src/TestUtils/Services/CurrencyServiceFake.cs index 7e88d92c..7a5e03ad 100644 --- a/src/TestUtils/Services/CurrencyServiceFake.cs +++ b/src/TestUtils/Services/CurrencyServiceFake.cs @@ -47,7 +47,7 @@ public Task> GetAllCurrenciesAsync(CancellationToken cance return Task.FromResult(_currencies); } - public Task ResetCacheAsync(CancellationToken cancellationToken) + public Task RefetchServiceCurrenciesAsync(CancellationToken cancellationToken) { throw new System.NotImplementedException(); } diff --git a/src/Web.Api/Features/BackgroundJobs/CurrenciesResetJob.cs b/src/Web.Api/Features/BackgroundJobs/RefetchServiceCurrenciesJob.cs similarity index 64% rename from src/Web.Api/Features/BackgroundJobs/CurrenciesResetJob.cs rename to src/Web.Api/Features/BackgroundJobs/RefetchServiceCurrenciesJob.cs index dc1e8697..a4abd49a 100644 --- a/src/Web.Api/Features/BackgroundJobs/CurrenciesResetJob.cs +++ b/src/Web.Api/Features/BackgroundJobs/RefetchServiceCurrenciesJob.cs @@ -5,12 +5,12 @@ namespace Web.Api.Features.BackgroundJobs; -public class CurrenciesResetJob : InvocableJobBase +public class RefetchServiceCurrenciesJob : InvocableJobBase { private readonly ICurrencyService _currencyService; - public CurrenciesResetJob( - ILogger logger, + public RefetchServiceCurrenciesJob( + ILogger logger, ICurrencyService currencyService) : base(logger) { @@ -20,6 +20,6 @@ public CurrenciesResetJob( public override async Task ExecuteAsync( CancellationToken cancellationToken = default) { - await _currencyService.ResetCacheAsync(cancellationToken); + await _currencyService.RefetchServiceCurrenciesAsync(cancellationToken); } } \ No newline at end of file diff --git a/src/Web.Api/Setup/ScheduleConfig.cs b/src/Web.Api/Setup/ScheduleConfig.cs index 9805a5b0..b41b383c 100644 --- a/src/Web.Api/Setup/ScheduleConfig.cs +++ b/src/Web.Api/Setup/ScheduleConfig.cs @@ -16,7 +16,7 @@ public static IServiceCollection SetupScheduler( { services .AddScheduler() - .AddTransient() + .AddTransient() .AddTransient() .AddTransient() .AddTransient() @@ -34,9 +34,10 @@ public static void Use(IApplicationBuilder app) app.ApplicationServices.UseScheduler((scheduler) => { scheduler - .Schedule() + .Schedule() .DailyAt(6, 0) - .RunOnceAtStart(); + .RunOnceAtStart() + .PreventOverlapping(nameof(RefetchServiceCurrenciesJob)); scheduler .Schedule() diff --git a/src/Web.Api/Setup/ServiceRegistration.cs b/src/Web.Api/Setup/ServiceRegistration.cs index 03ffe35a..3defa960 100644 --- a/src/Web.Api/Setup/ServiceRegistration.cs +++ b/src/Web.Api/Setup/ServiceRegistration.cs @@ -56,6 +56,7 @@ public static IServiceCollection SetupAppServices( .AddTransient() .AddTransient() .AddTransient() + .AddTransient() .AddTransient() .AddTransient() .AddTransient() From e8289b3bdd79db557a959946e8f6867e55530c57 Mon Sep 17 00:00:00 2001 From: "maxim.gorbatyuk" Date: Fri, 2 Jan 2026 15:26:27 +0500 Subject: [PATCH 3/5] Added migration --- ..._CurrencyCollectionEntityAdded.Designer.cs | 1977 +++++++++++++++++ ...102102556_CurrencyCollectionEntityAdded.cs | 43 + .../DatabaseContextModelSnapshot.cs | 26 + 3 files changed, 2046 insertions(+) create mode 100644 src/Infrastructure/Migrations/20260102102556_CurrencyCollectionEntityAdded.Designer.cs create mode 100644 src/Infrastructure/Migrations/20260102102556_CurrencyCollectionEntityAdded.cs diff --git a/src/Infrastructure/Migrations/20260102102556_CurrencyCollectionEntityAdded.Designer.cs b/src/Infrastructure/Migrations/20260102102556_CurrencyCollectionEntityAdded.Designer.cs new file mode 100644 index 00000000..ccc03a89 --- /dev/null +++ b/src/Infrastructure/Migrations/20260102102556_CurrencyCollectionEntityAdded.Designer.cs @@ -0,0 +1,1977 @@ +// +using System; +using Infrastructure.Database; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Domain.Migrations +{ + [DbContext(typeof(DatabaseContext))] + [Migration("20260102102556_CurrencyCollectionEntityAdded")] + partial class CurrencyCollectionEntityAdded + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.1") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("Domain.Entities.CSV.UserCsvDownload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserCsvDownloads", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Companies.Company", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .IsRequired() + .HasColumnType("text"); + + b.Property("Links") + .HasColumnType("text"); + + b.Property("LogoUrl") + .HasColumnType("text"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("NormalizedName") + .IsRequired() + .HasColumnType("text"); + + b.Property("Rating") + .HasColumnType("double precision"); + + b.Property("ReviewsCount") + .HasColumnType("integer"); + + b.Property("Slug") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ViewsCount") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("NormalizedName"); + + b.HasIndex("Slug") + .IsUnique(); + + b.ToTable("Companies", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Companies.CompanyOpenAiAnalysis", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AnalysisText") + .IsRequired() + .HasColumnType("text"); + + b.Property("CompanyId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Model") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("CompanyOpenAiAnalysisRecords", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Companies.CompanyRatingHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CompanyId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Rating") + .HasColumnType("double precision"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.ToTable("CompanyRatingHistory", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Companies.CompanyReview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ApprovedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CareerOpportunities") + .HasColumnType("integer"); + + b.Property("CodeQuality") + .HasColumnType("integer"); + + b.Property("CompanyId") + .HasColumnType("uuid"); + + b.Property("CompensationAndBenefits") + .HasColumnType("integer"); + + b.Property("Cons") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CultureAndValues") + .HasColumnType("integer"); + + b.Property("DislikesCount") + .HasColumnType("integer"); + + b.Property("IWorkHere") + .HasColumnType("boolean"); + + b.Property("LikesCount") + .HasColumnType("integer"); + + b.Property("Management") + .HasColumnType("integer"); + + b.Property("OutdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Pros") + .HasColumnType("text"); + + b.Property("TotalRating") + .HasColumnType("double precision"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserEmployment") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("WorkLifeBalance") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CompanyId"); + + b.HasIndex("UserId"); + + b.ToTable("CompanyReviews", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Companies.CompanyReviewVote", b => + { + b.Property("ReviewId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("VoteType") + .HasColumnType("integer"); + + b.HasKey("ReviewId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("CompanyReviewVotes"); + }); + + modelBuilder.Entity("Domain.Entities.Currencies.CurrenciesCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currencies") + .HasColumnType("text"); + + b.Property("CurrencyDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CurrencyDate") + .IsUnique(); + + b.ToTable("CurrenciesCollections", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Github.GithubPersonalUserToken", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Token") + .IsRequired() + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("GithubPersonalUserTokens", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Github.GithubProfile", b => + { + b.Property("Username") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("DataSyncedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("RequestsCount") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Version") + .HasColumnType("integer"); + + b.HasKey("Username"); + + b.ToTable("GithubProfiles", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Github.GithubProfileBotChat", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChatId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("MessagesCount") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Username") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("Id"); + + b.HasIndex("ChatId") + .IsUnique(); + + b.ToTable("GithubProfileBotChats", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Github.GithubProfileBotMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("GithubProfileBotChatId") + .HasColumnType("uuid"); + + b.Property("Text") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("GithubProfileBotChatId"); + + b.ToTable("GithubProfileBotMessages", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Github.GithubProfileProcessingJob", b => + { + b.Property("Username") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Username"); + + b.ToTable("GithubProfileProcessingJobs", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.HistoricalRecords.SalariesHistoricalDataRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("Date") + .HasColumnType("timestamp with time zone"); + + b.Property("TemplateId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Date"); + + b.HasIndex("TemplateId"); + + b.ToTable("SalariesHistoricalDataRecords", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.HistoricalRecords.SalariesHistoricalDataRecordTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProfessionIds") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("SalariesHistoricalDataRecordTemplates", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Interviews.Interview", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CandidateGrade") + .HasColumnType("bigint"); + + b.Property("CandidateName") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InterviewerId") + .HasColumnType("bigint"); + + b.Property("OverallOpinion") + .HasMaxLength(50000) + .HasColumnType("character varying(50000)"); + + b.Property("Subjects") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("InterviewerId"); + + b.ToTable("Interviews", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Interviews.InterviewTemplate", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AuthorId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsPublic") + .HasColumnType("boolean"); + + b.Property("OverallOpinion") + .HasMaxLength(50000) + .HasColumnType("character varying(50000)"); + + b.Property("Subjects") + .HasColumnType("text"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("AuthorId"); + + b.ToTable("InterviewTemplates", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Interviews.ShareLink", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InterviewId") + .HasColumnType("uuid"); + + b.Property("ShareToken") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("InterviewId") + .IsUnique(); + + b.ToTable("ShareLinks", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Labels.UserLabel", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("HexColor") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("UserLabels", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Prompts.OpenAiPrompt", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Engine") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(1); + + b.Property("IsActive") + .HasColumnType("boolean"); + + b.Property("Model") + .IsRequired() + .HasColumnType("text"); + + b.Property("Prompt") + .IsRequired() + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Type", "IsActive"); + + b.ToTable("OpenAiPrompts", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Questions.SalariesSurveyReply", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CreatedByUserId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UsefulnessRating") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("CreatedByUserId"); + + b.ToTable("SalariesSurveyReplies", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Salaries.Profession", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("HexColor") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("Professions", (string)null); + + b.HasData( + new + { + Id = 1L, + HexColor = "#C00C50", + Title = "Developer" + }, + new + { + Id = 2L, + HexColor = "#C00C50", + Title = "QualityAssurance" + }, + new + { + Id = 3L, + HexColor = "#C00C50", + Title = "Tester" + }, + new + { + Id = 4L, + HexColor = "#C00C50", + Title = "BusinessAnalyst" + }, + new + { + Id = 5L, + HexColor = "#C00C50", + Title = "ProjectManager" + }, + new + { + Id = 6L, + HexColor = "#C00C50", + Title = "ScrumMaster" + }, + new + { + Id = 7L, + HexColor = "#C00C50", + Title = "DevOps" + }, + new + { + Id = 8L, + HexColor = "#C00C50", + Title = "SystemAdministrator" + }, + new + { + Id = 9L, + HexColor = "#C00C50", + Title = "ProductOwner" + }, + new + { + Id = 10L, + HexColor = "#C00C50", + Title = "TeamLeader" + }, + new + { + Id = 11L, + HexColor = "#C00C50", + Title = "Architect" + }, + new + { + Id = 12L, + HexColor = "#C00C50", + Title = "DataScientist" + }, + new + { + Id = 13L, + HexColor = "#C00C50", + Title = "DataAnalyst" + }, + new + { + Id = 14L, + HexColor = "#C00C50", + Title = "DataEngineer" + }, + new + { + Id = 15L, + HexColor = "#C00C50", + Title = "DataWarehouseSpecialist" + }, + new + { + Id = 16L, + HexColor = "#C00C50", + Title = "DatabaseAdministrator" + }, + new + { + Id = 17L, + HexColor = "#C00C50", + Title = "TechLeader" + }, + new + { + Id = 18L, + HexColor = "#C00C50", + Title = "SystemAnalyst" + }, + new + { + Id = 19L, + HexColor = "#C00C50", + Title = "ItHr" + }, + new + { + Id = 20L, + HexColor = "#C00C50", + Title = "ItRecruiter" + }, + new + { + Id = 21L, + HexColor = "#C00C50", + Title = "UiDesigner" + }, + new + { + Id = 22L, + HexColor = "#C00C50", + Title = "UxDesigner" + }, + new + { + Id = 23L, + HexColor = "#C00C50", + Title = "UiUxDesigner" + }, + new + { + Id = 24L, + HexColor = "#C00C50", + Title = "ProductAnalyst" + }, + new + { + Id = 25L, + HexColor = "#C00C50", + Title = "ProductManager" + }, + new + { + Id = 26L, + HexColor = "#C00C50", + Title = "ProductDesigner" + }, + new + { + Id = 27L, + HexColor = "#C00C50", + Title = "HrNonIt" + }, + new + { + Id = 28L, + HexColor = "#C00C50", + Title = "OneCDeveloper" + }, + new + { + Id = 29L, + HexColor = "#C00C50", + Title = "ThreeDModeler" + }, + new + { + Id = 30L, + HexColor = "#C00C50", + Title = "AndroidDeveloper" + }, + new + { + Id = 31L, + HexColor = "#C00C50", + Title = "IosDeveloper" + }, + new + { + Id = 32L, + HexColor = "#C00C50", + Title = "MobileDeveloper" + }, + new + { + Id = 33L, + HexColor = "#C00C50", + Title = "FrontendDeveloper" + }, + new + { + Id = 34L, + HexColor = "#C00C50", + Title = "BackendDeveloper" + }, + new + { + Id = 35L, + HexColor = "#C00C50", + Title = "FullstackDeveloper" + }, + new + { + Id = 36L, + HexColor = "#C00C50", + Title = "GameDeveloper" + }, + new + { + Id = 37L, + HexColor = "#C00C50", + Title = "EmbeddedDeveloper" + }, + new + { + Id = 38L, + HexColor = "#C00C50", + Title = "MachineLearningDeveloper" + }, + new + { + Id = 39L, + HexColor = "#C00C50", + Title = "Pentester" + }, + new + { + Id = 40L, + HexColor = "#C00C50", + Title = "SecurityEngineer" + }, + new + { + Id = 41L, + HexColor = "#C00C50", + Title = "SecurityAnalyst" + }, + new + { + Id = 42L, + HexColor = "#C00C50", + Title = "TechnicalWriter" + }, + new + { + Id = 43L, + HexColor = "#C00C50", + Title = "BiDeveloper" + }, + new + { + Id = 44L, + HexColor = "#C00C50", + Title = "ChiefTechnicalOfficer" + }, + new + { + Id = 45L, + HexColor = "#C00C50", + Title = "ChiefExecutiveOfficer" + }, + new + { + Id = 46L, + HexColor = "#C00C50", + Title = "HeadOfDepartment" + }, + new + { + Id = 47L, + HexColor = "#C00C50", + Title = "DeliveryManager" + }, + new + { + Id = 48L, + HexColor = "#C00C50", + Title = "Copywriter" + }, + new + { + Id = 49L, + HexColor = "#C00C50", + Title = "GameDesigner" + }, + new + { + Id = 50L, + HexColor = "#C00C50", + Title = "SecOps" + }, + new + { + Id = 51L, + HexColor = "#C00C50", + Title = "TalentAcquisition" + }, + new + { + Id = 52L, + HexColor = "#C00C50", + Title = "CustomerSupport" + }, + new + { + Id = 53L, + HexColor = "#C00C50", + Title = "TechnicalSupport" + }); + }); + + modelBuilder.Entity("Domain.Entities.Salaries.Skill", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("HexColor") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("Skills", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Salaries.UserSalary", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Age") + .HasColumnType("integer"); + + b.Property("City") + .HasColumnType("bigint"); + + b.Property("Company") + .HasColumnType("integer"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currency") + .HasColumnType("integer"); + + b.Property("Gender") + .HasColumnType("integer"); + + b.Property("Grade") + .HasColumnType("bigint"); + + b.Property("ProfessionEnum") + .HasColumnType("integer"); + + b.Property("ProfessionId") + .HasColumnType("bigint"); + + b.Property("Quarter") + .HasColumnType("integer"); + + b.Property("SkillId") + .HasColumnType("bigint"); + + b.Property("SourceType") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UseInStats") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(true); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Value") + .HasColumnType("double precision"); + + b.Property("WorkIndustryId") + .HasColumnType("bigint"); + + b.Property("Year") + .HasColumnType("integer"); + + b.Property("YearOfStartingWork") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("ProfessionId"); + + b.HasIndex("SkillId"); + + b.HasIndex("UserId"); + + b.HasIndex("WorkIndustryId"); + + b.ToTable("UserSalaries", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Salaries.WorkIndustry", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedById") + .HasColumnType("bigint"); + + b.Property("HexColor") + .IsRequired() + .HasMaxLength(7) + .HasColumnType("character varying(7)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.HasKey("Id"); + + b.HasIndex("CreatedById"); + + b.ToTable("WorkIndustries", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.StatData.AiAnalysisRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AiReport") + .IsRequired() + .HasColumnType("text"); + + b.Property("AiReportSource") + .IsRequired() + .HasColumnType("text"); + + b.Property("CompanyReviewsSubscriptionId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Model") + .HasColumnType("text"); + + b.Property("ProcessingTimeMs") + .HasColumnType("double precision"); + + b.Property("SubscriptionId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReviewsSubscriptionId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("AiAnalysisRecords", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.StatData.CompanyReviews.LastWeekCompanyReviewsSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("Regularity") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Weekly"); + + b.Property("TelegramChatId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UseAiAnalysis") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("LastWeekCompanyReviewsSubscriptions", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.StatData.Salary.JobPostingMessageSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasColumnType("text"); + + b.Property("ProfessionIds") + .HasColumnType("text"); + + b.Property("TelegramChatId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("TelegramChatId") + .IsUnique(); + + b.ToTable("JobPostingMessageSubscriptions", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.StatData.Salary.StatDataChangeSubscription", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(500) + .HasColumnType("character varying(500)"); + + b.Property("PreventNotificationIfNoDifference") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.Property("ProfessionIds") + .HasColumnType("text"); + + b.Property("Regularity") + .IsRequired() + .ValueGeneratedOnAdd() + .HasColumnType("text") + .HasDefaultValue("Weekly"); + + b.Property("TelegramChatId") + .HasColumnType("bigint"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UseAiAnalysis") + .ValueGeneratedOnAdd() + .HasColumnType("boolean") + .HasDefaultValue(false); + + b.HasKey("Id"); + + b.ToTable("StatDataChangeSubscriptions", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.StatData.Salary.StatDataChangeSubscriptionRecord", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Data") + .HasColumnType("text"); + + b.Property("PreviousRecordId") + .HasColumnType("uuid"); + + b.Property("SubscriptionId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("PreviousRecordId"); + + b.HasIndex("SubscriptionId"); + + b.ToTable("StatDataChangeSubscriptionRecords", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.StatData.SubscriptionTelegramMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChatId") + .HasColumnType("bigint"); + + b.Property("CompanyReviewsSubscriptionId") + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Message") + .HasColumnType("text"); + + b.Property("SalarySubscriptionId") + .HasColumnType("uuid"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CompanyReviewsSubscriptionId"); + + b.HasIndex("SalarySubscriptionId"); + + b.ToTable("StatDataChangeSubscriptionTgMessages", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Telegram.SalariesBotMessage", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChatId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("IsAdmin") + .HasColumnType("boolean"); + + b.Property("UsageType") + .HasColumnType("integer"); + + b.Property("Username") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("SalariesBotMessages", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Telegram.TelegramInlineReply", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("BotType") + .HasColumnType("integer"); + + b.Property("ChatId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("InlineQuery") + .HasColumnType("text"); + + b.HasKey("Id"); + + b.ToTable("TelegramInlineReplies", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Telegram.TelegramUserSettings", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("ChatId") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("SendBotRegularStatsUpdates") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Username") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.HasKey("Id"); + + b.HasIndex("ChatId") + .IsUnique(); + + b.HasIndex("UserId"); + + b.ToTable("TelegramUserSettings", (string)null); + }); + + modelBuilder.Entity("Domain.Entities.Users.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DeletedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("EmailConfirmed") + .HasColumnType("boolean"); + + b.Property("FirstName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("IdentityId") + .HasColumnType("text"); + + b.Property("LastLoginAt") + .HasColumnType("timestamp with time zone"); + + b.Property("LastName") + .IsRequired() + .HasMaxLength(150) + .HasColumnType("character varying(150)"); + + b.Property("ProfilePicture") + .HasColumnType("text"); + + b.Property("TotpSecret") + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("TotpVerifiedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UniqueToken") + .HasColumnType("text"); + + b.Property("UnsubscribeMeFromAll") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("IdentityId") + .IsUnique(); + + b.HasIndex("UniqueToken") + .IsUnique(); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("Domain.Entities.Users.UserEmail", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("EmailSubject") + .HasColumnType("text"); + + b.Property("Type") + .HasColumnType("integer"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UserEmails"); + }); + + modelBuilder.Entity("Domain.Entities.Users.UserRole", b => + { + b.Property("RoleId") + .HasColumnType("bigint"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("RoleId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("UserRole"); + }); + + modelBuilder.Entity("InterviewTemplateUserLabel", b => + { + b.Property("InterviewTemplatesId") + .HasColumnType("uuid"); + + b.Property("LabelsId") + .HasColumnType("bigint"); + + b.HasKey("InterviewTemplatesId", "LabelsId"); + + b.HasIndex("LabelsId"); + + b.ToTable("InterviewTemplateUserLabel"); + }); + + modelBuilder.Entity("InterviewUserLabel", b => + { + b.Property("InterviewsId") + .HasColumnType("uuid"); + + b.Property("LabelsId") + .HasColumnType("bigint"); + + b.HasKey("InterviewsId", "LabelsId"); + + b.HasIndex("LabelsId"); + + b.ToTable("InterviewUserLabel"); + }); + + modelBuilder.Entity("Domain.Entities.CSV.UserCsvDownload", b => + { + b.HasOne("Domain.Entities.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Companies.CompanyOpenAiAnalysis", b => + { + b.HasOne("Domain.Entities.Companies.Company", "Company") + .WithMany("OpenAiAnalysisRecords") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("Domain.Entities.Companies.CompanyRatingHistory", b => + { + b.HasOne("Domain.Entities.Companies.Company", "Company") + .WithMany("RatingHistory") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Company"); + }); + + modelBuilder.Entity("Domain.Entities.Companies.CompanyReview", b => + { + b.HasOne("Domain.Entities.Companies.Company", "Company") + .WithMany("Reviews") + .HasForeignKey("CompanyId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.Users.User", "User") + .WithMany("Reviews") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Company"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Companies.CompanyReviewVote", b => + { + b.HasOne("Domain.Entities.Companies.CompanyReview", "CompanyReview") + .WithMany("Votes") + .HasForeignKey("ReviewId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.Users.User", "User") + .WithMany("Votes") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("CompanyReview"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Github.GithubProfileBotMessage", b => + { + b.HasOne("Domain.Entities.Github.GithubProfileBotChat", "GithubProfileBotChat") + .WithMany("Messages") + .HasForeignKey("GithubProfileBotChatId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("GithubProfileBotChat"); + }); + + modelBuilder.Entity("Domain.Entities.HistoricalRecords.SalariesHistoricalDataRecord", b => + { + b.HasOne("Domain.Entities.HistoricalRecords.SalariesHistoricalDataRecordTemplate", "SalariesHistoricalDataRecordTemplate") + .WithMany("SalariesHistoricalDataRecords") + .HasForeignKey("TemplateId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("SalariesHistoricalDataRecordTemplate"); + }); + + modelBuilder.Entity("Domain.Entities.Interviews.Interview", b => + { + b.HasOne("Domain.Entities.Users.User", "Interviewer") + .WithMany() + .HasForeignKey("InterviewerId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Interviewer"); + }); + + modelBuilder.Entity("Domain.Entities.Interviews.InterviewTemplate", b => + { + b.HasOne("Domain.Entities.Users.User", "Author") + .WithMany() + .HasForeignKey("AuthorId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Author"); + }); + + modelBuilder.Entity("Domain.Entities.Interviews.ShareLink", b => + { + b.HasOne("Domain.Entities.Interviews.Interview", "Interview") + .WithOne("ShareLink") + .HasForeignKey("Domain.Entities.Interviews.ShareLink", "InterviewId"); + + b.Navigation("Interview"); + }); + + modelBuilder.Entity("Domain.Entities.Labels.UserLabel", b => + { + b.HasOne("Domain.Entities.Users.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById"); + + b.Navigation("CreatedBy"); + }); + + modelBuilder.Entity("Domain.Entities.Questions.SalariesSurveyReply", b => + { + b.HasOne("Domain.Entities.Users.User", "CreatedByUser") + .WithMany() + .HasForeignKey("CreatedByUserId"); + + b.Navigation("CreatedByUser"); + }); + + modelBuilder.Entity("Domain.Entities.Salaries.Profession", b => + { + b.HasOne("Domain.Entities.Users.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById"); + + b.Navigation("CreatedBy"); + }); + + modelBuilder.Entity("Domain.Entities.Salaries.Skill", b => + { + b.HasOne("Domain.Entities.Users.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById"); + + b.Navigation("CreatedBy"); + }); + + modelBuilder.Entity("Domain.Entities.Salaries.UserSalary", b => + { + b.HasOne("Domain.Entities.Salaries.Profession", "Profession") + .WithMany("Salaries") + .HasForeignKey("ProfessionId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Domain.Entities.Salaries.Skill", "Skill") + .WithMany("Salaries") + .HasForeignKey("SkillId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Domain.Entities.Users.User", "User") + .WithMany("Salaries") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("Domain.Entities.Salaries.WorkIndustry", "WorkIndustry") + .WithMany("Salaries") + .HasForeignKey("WorkIndustryId") + .OnDelete(DeleteBehavior.SetNull); + + b.Navigation("Profession"); + + b.Navigation("Skill"); + + b.Navigation("User"); + + b.Navigation("WorkIndustry"); + }); + + modelBuilder.Entity("Domain.Entities.Salaries.WorkIndustry", b => + { + b.HasOne("Domain.Entities.Users.User", "CreatedBy") + .WithMany() + .HasForeignKey("CreatedById"); + + b.Navigation("CreatedBy"); + }); + + modelBuilder.Entity("Domain.Entities.StatData.AiAnalysisRecord", b => + { + b.HasOne("Domain.Entities.StatData.CompanyReviews.LastWeekCompanyReviewsSubscription", "CompanyReviewsSubscription") + .WithMany("AiAnalysisRecords") + .HasForeignKey("CompanyReviewsSubscriptionId"); + + b.HasOne("Domain.Entities.StatData.Salary.StatDataChangeSubscription", "SalarySubscription") + .WithMany("AiAnalysisRecords") + .HasForeignKey("SubscriptionId"); + + b.Navigation("CompanyReviewsSubscription"); + + b.Navigation("SalarySubscription"); + }); + + modelBuilder.Entity("Domain.Entities.StatData.Salary.StatDataChangeSubscriptionRecord", b => + { + b.HasOne("Domain.Entities.StatData.Salary.StatDataChangeSubscriptionRecord", "PreviousRecord") + .WithMany("NextRecords") + .HasForeignKey("PreviousRecordId") + .OnDelete(DeleteBehavior.SetNull) + .HasConstraintName("FK_StatDataChangeSubscriptionRecord_PreviousRecord"); + + b.HasOne("Domain.Entities.StatData.Salary.StatDataChangeSubscription", "Subscription") + .WithMany("Records") + .HasForeignKey("SubscriptionId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired() + .HasConstraintName("FK_StatDataChangeSubscriptionRecord_Subscription"); + + b.Navigation("PreviousRecord"); + + b.Navigation("Subscription"); + }); + + modelBuilder.Entity("Domain.Entities.StatData.SubscriptionTelegramMessage", b => + { + b.HasOne("Domain.Entities.StatData.CompanyReviews.LastWeekCompanyReviewsSubscription", "CompanyReviewsSubscription") + .WithMany("TelegramMessages") + .HasForeignKey("CompanyReviewsSubscriptionId") + .OnDelete(DeleteBehavior.Cascade); + + b.HasOne("Domain.Entities.StatData.Salary.StatDataChangeSubscription", "SalarySubscription") + .WithMany("StatDataChangeSubscriptionTgMessages") + .HasForeignKey("SalarySubscriptionId") + .OnDelete(DeleteBehavior.Cascade); + + b.Navigation("CompanyReviewsSubscription"); + + b.Navigation("SalarySubscription"); + }); + + modelBuilder.Entity("Domain.Entities.Telegram.TelegramUserSettings", b => + { + b.HasOne("Domain.Entities.Users.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Users.UserEmail", b => + { + b.HasOne("Domain.Entities.Users.User", "User") + .WithMany("Emails") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Domain.Entities.Users.UserRole", b => + { + b.HasOne("Domain.Entities.Users.User", "User") + .WithMany("UserRoles") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("InterviewTemplateUserLabel", b => + { + b.HasOne("Domain.Entities.Interviews.InterviewTemplate", null) + .WithMany() + .HasForeignKey("InterviewTemplatesId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.Labels.UserLabel", null) + .WithMany() + .HasForeignKey("LabelsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("InterviewUserLabel", b => + { + b.HasOne("Domain.Entities.Interviews.Interview", null) + .WithMany() + .HasForeignKey("InterviewsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Domain.Entities.Labels.UserLabel", null) + .WithMany() + .HasForeignKey("LabelsId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + }); + + modelBuilder.Entity("Domain.Entities.Companies.Company", b => + { + b.Navigation("OpenAiAnalysisRecords"); + + b.Navigation("RatingHistory"); + + b.Navigation("Reviews"); + }); + + modelBuilder.Entity("Domain.Entities.Companies.CompanyReview", b => + { + b.Navigation("Votes"); + }); + + modelBuilder.Entity("Domain.Entities.Github.GithubProfileBotChat", b => + { + b.Navigation("Messages"); + }); + + modelBuilder.Entity("Domain.Entities.HistoricalRecords.SalariesHistoricalDataRecordTemplate", b => + { + b.Navigation("SalariesHistoricalDataRecords"); + }); + + modelBuilder.Entity("Domain.Entities.Interviews.Interview", b => + { + b.Navigation("ShareLink"); + }); + + modelBuilder.Entity("Domain.Entities.Salaries.Profession", b => + { + b.Navigation("Salaries"); + }); + + modelBuilder.Entity("Domain.Entities.Salaries.Skill", b => + { + b.Navigation("Salaries"); + }); + + modelBuilder.Entity("Domain.Entities.Salaries.WorkIndustry", b => + { + b.Navigation("Salaries"); + }); + + modelBuilder.Entity("Domain.Entities.StatData.CompanyReviews.LastWeekCompanyReviewsSubscription", b => + { + b.Navigation("AiAnalysisRecords"); + + b.Navigation("TelegramMessages"); + }); + + modelBuilder.Entity("Domain.Entities.StatData.Salary.StatDataChangeSubscription", b => + { + b.Navigation("AiAnalysisRecords"); + + b.Navigation("Records"); + + b.Navigation("StatDataChangeSubscriptionTgMessages"); + }); + + modelBuilder.Entity("Domain.Entities.StatData.Salary.StatDataChangeSubscriptionRecord", b => + { + b.Navigation("NextRecords"); + }); + + modelBuilder.Entity("Domain.Entities.Users.User", b => + { + b.Navigation("Emails"); + + b.Navigation("Reviews"); + + b.Navigation("Salaries"); + + b.Navigation("UserRoles"); + + b.Navigation("Votes"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/Infrastructure/Migrations/20260102102556_CurrencyCollectionEntityAdded.cs b/src/Infrastructure/Migrations/20260102102556_CurrencyCollectionEntityAdded.cs new file mode 100644 index 00000000..77bcdd18 --- /dev/null +++ b/src/Infrastructure/Migrations/20260102102556_CurrencyCollectionEntityAdded.cs @@ -0,0 +1,43 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Domain.Migrations +{ + /// + public partial class CurrencyCollectionEntityAdded : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "CurrenciesCollections", + columns: table => new + { + Id = table.Column(type: "uuid", nullable: false), + Currencies = table.Column(type: "text", nullable: true), + CurrencyDate = table.Column(type: "timestamp with time zone", nullable: false), + CreatedAt = table.Column(type: "timestamp with time zone", nullable: false), + UpdatedAt = table.Column(type: "timestamp with time zone", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CurrenciesCollections", x => x.Id); + }); + + migrationBuilder.CreateIndex( + name: "IX_CurrenciesCollections_CurrencyDate", + table: "CurrenciesCollections", + column: "CurrencyDate", + unique: true); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CurrenciesCollections"); + } + } +} \ No newline at end of file diff --git a/src/Infrastructure/Migrations/DatabaseContextModelSnapshot.cs b/src/Infrastructure/Migrations/DatabaseContextModelSnapshot.cs index 5eac2504..beaa3e4f 100644 --- a/src/Infrastructure/Migrations/DatabaseContextModelSnapshot.cs +++ b/src/Infrastructure/Migrations/DatabaseContextModelSnapshot.cs @@ -243,6 +243,32 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.ToTable("CompanyReviewVotes"); }); + modelBuilder.Entity("Domain.Entities.Currencies.CurrenciesCollection", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Currencies") + .HasColumnType("text"); + + b.Property("CurrencyDate") + .HasColumnType("timestamp with time zone"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("CurrencyDate") + .IsUnique(); + + b.ToTable("CurrenciesCollections", (string)null); + }); + modelBuilder.Entity("Domain.Entities.Github.GithubPersonalUserToken", b => { b.Property("Id") From 6aff45668e6fa2cfd81947aec156b00edf483e4f Mon Sep 17 00:00:00 2001 From: "maxim.gorbatyuk" Date: Fri, 2 Jan 2026 18:10:53 +0500 Subject: [PATCH 4/5] CR fix --- .../Currencies/CurrenciesCollection.cs | 8 +- .../Currencies/CurrenciesHttpService.cs | 7 +- .../Currencies/ICurrenciesHttpService.cs | 3 +- .../Services/CurrenciesHttpServiceTests.cs | 2 +- .../Services/CurrencyServiceTests.cs | 98 ++++++++++++++----- .../Fakes/CurrenciesCollectionFake.cs | 29 ++++-- 6 files changed, 104 insertions(+), 43 deletions(-) diff --git a/src/Domain/Entities/Currencies/CurrenciesCollection.cs b/src/Domain/Entities/Currencies/CurrenciesCollection.cs index 585abad9..8ae19370 100644 --- a/src/Domain/Entities/Currencies/CurrenciesCollection.cs +++ b/src/Domain/Entities/Currencies/CurrenciesCollection.cs @@ -14,7 +14,7 @@ public class CurrenciesCollection : HasDatesBase, IHasIdBase public DateTime CurrencyDate { get; protected set; } public CurrenciesCollection( - List currencies) + Dictionary currencies) { if (currencies.Count == 0) { @@ -22,13 +22,13 @@ public CurrenciesCollection( } Id = Guid.NewGuid(); - CurrencyDate = currencies[0].PubDate; + CurrencyDate = currencies.First().Value.PubDate; CreatedAt = UpdatedAt = DateTimeOffset.UtcNow; Currencies = currencies .ToDictionary( - x => x.Currency, - x => x.Value); + x => x.Key, + x => x.Value.Value); } public List CreateCurrencies() diff --git a/src/Infrastructure/Currencies/CurrenciesHttpService.cs b/src/Infrastructure/Currencies/CurrenciesHttpService.cs index ece3312c..b35278bc 100644 --- a/src/Infrastructure/Currencies/CurrenciesHttpService.cs +++ b/src/Infrastructure/Currencies/CurrenciesHttpService.cs @@ -23,7 +23,7 @@ public CurrenciesHttpService( _logger = logger; } - public async Task> GetCurrenciesAsync( + public async Task> GetCurrenciesAsync( CancellationToken cancellationToken) { var currenciesUrl = _configuration["Currencies:Url"]; @@ -42,7 +42,8 @@ public async Task> GetCurrenciesAsync( return xdoc.Descendants("item") .Select(x => new CurrencyContent(x)) .Where(x => currenciesToSave.Contains(x.Currency)) - .ToList(); + .GroupBy(x => x.Currency) + .ToDictionary(x => x.Key, x => x.First()); } catch (Exception e) { @@ -52,7 +53,7 @@ public async Task> GetCurrenciesAsync( currenciesUrl, e.Message); - return new List(0); + return new Dictionary(0); } } } \ No newline at end of file diff --git a/src/Infrastructure/Currencies/ICurrenciesHttpService.cs b/src/Infrastructure/Currencies/ICurrenciesHttpService.cs index c3bbe4d1..b3febe95 100644 --- a/src/Infrastructure/Currencies/ICurrenciesHttpService.cs +++ b/src/Infrastructure/Currencies/ICurrenciesHttpService.cs @@ -1,9 +1,10 @@ using Domain.Entities.Currencies; +using Domain.Entities.Salaries; namespace Infrastructure.Currencies; public interface ICurrenciesHttpService { - Task> GetCurrenciesAsync( + Task> GetCurrenciesAsync( CancellationToken cancellationToken); } \ No newline at end of file diff --git a/src/InfrastructureTests/Services/CurrenciesHttpServiceTests.cs b/src/InfrastructureTests/Services/CurrenciesHttpServiceTests.cs index 147b07dc..1a13a0aa 100644 --- a/src/InfrastructureTests/Services/CurrenciesHttpServiceTests.cs +++ b/src/InfrastructureTests/Services/CurrenciesHttpServiceTests.cs @@ -409,7 +409,7 @@ public async Task GetCurrenciesAsync_ValidResponse_ReturnsAllCurrencies() Assert.Equal(existingCurrencies.Count, currencies.Count); foreach (var currency in currencies) { - Assert.Contains(currency.Currency, existingCurrencies); + Assert.Contains(currency.Key, existingCurrencies); } } } \ No newline at end of file diff --git a/src/InfrastructureTests/Services/CurrencyServiceTests.cs b/src/InfrastructureTests/Services/CurrencyServiceTests.cs index 9dab0045..d76dd941 100644 --- a/src/InfrastructureTests/Services/CurrencyServiceTests.cs +++ b/src/InfrastructureTests/Services/CurrencyServiceTests.cs @@ -3,6 +3,7 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using Domain.Entities.Currencies; using Domain.Entities.Salaries; using Infrastructure.Currencies; using MemoryCache.Testing.Moq; @@ -25,16 +26,22 @@ public async Task GetCurrenciesAsync_NoSavedDatabaseEntities_ReturnsOneCurrency( var currenciesHttpService = new Mock(); currenciesHttpService .Setup(x => x.GetCurrenciesAsync(It.IsAny())) - .ReturnsAsync(new List + .ReturnsAsync(new Dictionary { - new ( - 400, + { Currency.USD, - now), - new ( - 500, + new CurrencyContent( + 400, + Currency.USD, + now) + }, + { Currency.EUR, - now), + new CurrencyContent( + 500, + Currency.EUR, + now) + }, }); using var mockedCache = Create.MockedMemoryCache(); @@ -70,19 +77,26 @@ public async Task GetAllCurrenciesAsync_NoSavedDatabaseRecord_ReturnsAllCurrenci { await using var context = new InMemoryDatabaseContext(); var now = DateTime.UtcNow; + var currenciesHttpService = new Mock(); currenciesHttpService .Setup(x => x.GetCurrenciesAsync(It.IsAny())) - .ReturnsAsync(new List + .ReturnsAsync(new Dictionary { - new ( - 400, + { Currency.USD, - now), - new ( - 500, + new CurrencyContent( + 400, + Currency.USD, + now) + }, + { Currency.EUR, - now), + new CurrencyContent( + 500, + Currency.EUR, + now) + }, }); using var mockedCache = Create.MockedMemoryCache(); @@ -129,10 +143,22 @@ public async Task RefetchServiceCurrenciesAsync_HttpServiceReturnsRecords_NewRec var currenciesHttpService = new Mock(); currenciesHttpService .Setup(x => x.GetCurrenciesAsync(It.IsAny())) - .ReturnsAsync(new List + .ReturnsAsync(new Dictionary { - new (400, Currency.USD, now), - new (500, Currency.EUR, now), + { + Currency.USD, + new CurrencyContent( + 400, + Currency.USD, + now) + }, + { + Currency.EUR, + new CurrencyContent( + 500, + Currency.EUR, + now) + }, }); using var mockedCache = Create.MockedMemoryCache(); @@ -169,7 +195,7 @@ public async Task RefetchServiceCurrenciesAsync_HttpServiceReturnsNoRecords_NoRe var currenciesHttpService = new Mock(); currenciesHttpService .Setup(x => x.GetCurrenciesAsync(It.IsAny())) - .ReturnsAsync(new List()); + .ReturnsAsync(new Dictionary()); using var mockedCache = Create.MockedMemoryCache(); @@ -206,10 +232,22 @@ public async Task RefetchServiceCurrenciesAsync_SomeDbRecordsForPrevousDate_NewR var currenciesHttpService = new Mock(); currenciesHttpService .Setup(x => x.GetCurrenciesAsync(It.IsAny())) - .ReturnsAsync(new List + .ReturnsAsync(new Dictionary { - new (400, Currency.USD, now), - new (500, Currency.EUR, now), + { + Currency.USD, + new CurrencyContent( + 400, + Currency.USD, + now) + }, + { + Currency.EUR, + new CurrencyContent( + 500, + Currency.EUR, + now) + }, }); using var mockedCache = Create.MockedMemoryCache(); @@ -253,10 +291,22 @@ public async Task RefetchServiceCurrenciesAsync_ExistingRecordForSameDate_NoNewR var currenciesHttpService = new Mock(); currenciesHttpService .Setup(x => x.GetCurrenciesAsync(It.IsAny())) - .ReturnsAsync(new List + .ReturnsAsync(new Dictionary { - new (400, Currency.USD, now), - new (500, Currency.EUR, now), + { + Currency.USD, + new CurrencyContent( + 400, + Currency.USD, + now) + }, + { + Currency.EUR, + new CurrencyContent( + 500, + Currency.EUR, + now) + }, }); using var mockedCache = Create.MockedMemoryCache(); diff --git a/src/TestUtils/Fakes/CurrenciesCollectionFake.cs b/src/TestUtils/Fakes/CurrenciesCollectionFake.cs index 4eefd4ef..dab3ee0e 100644 --- a/src/TestUtils/Fakes/CurrenciesCollectionFake.cs +++ b/src/TestUtils/Fakes/CurrenciesCollectionFake.cs @@ -10,26 +10,35 @@ public class CurrenciesCollectionFake : CurrenciesCollection { public CurrenciesCollectionFake( DateTime currencyDate, - List currencies = null) + Dictionary currencies = null) : base( currencies ?? CreateDefaultCurrencies(currencyDate)) { } - public static List CreateDefaultCurrencies( + public static Dictionary CreateDefaultCurrencies( DateTime currencyDate) { - return new List() + return new Dictionary { - new CurrencyContent( - 400, + { Currency.USD, - currencyDate), - new CurrencyContent( - 500, + new CurrencyContent( + 400, + Currency.USD, + currencyDate) + }, + { Currency.EUR, - currencyDate), - new KztCurrencyContent(currencyDate), + new CurrencyContent( + 500, + Currency.EUR, + currencyDate) + }, + { + Currency.KZT, + new KztCurrencyContent(currencyDate) + } }; } From ff69542ae9586a4b68874e1553bfc45f065ba99b Mon Sep 17 00:00:00 2001 From: "maxim.gorbatyuk" Date: Fri, 2 Jan 2026 18:12:28 +0500 Subject: [PATCH 5/5] Fix --- src/Infrastructure/Currencies/CurrenciesHttpService.cs | 2 +- .../Services/AiServices/Custom/CustomOpenAiService.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Infrastructure/Currencies/CurrenciesHttpService.cs b/src/Infrastructure/Currencies/CurrenciesHttpService.cs index b35278bc..088892f1 100644 --- a/src/Infrastructure/Currencies/CurrenciesHttpService.cs +++ b/src/Infrastructure/Currencies/CurrenciesHttpService.cs @@ -34,7 +34,7 @@ public async Task> GetCurrenciesAsync( try { - using var client = _httpClientFactory.CreateClient(); + var client = _httpClientFactory.CreateClient(); var xmlContent = await client.GetStringAsync(currenciesUrl, cancellationToken); var xdoc = XDocument.Parse(xmlContent); diff --git a/src/Infrastructure/Services/AiServices/Custom/CustomOpenAiService.cs b/src/Infrastructure/Services/AiServices/Custom/CustomOpenAiService.cs index 574f3e70..d01915f3 100644 --- a/src/Infrastructure/Services/AiServices/Custom/CustomOpenAiService.cs +++ b/src/Infrastructure/Services/AiServices/Custom/CustomOpenAiService.cs @@ -43,7 +43,7 @@ public async Task GetAnalysisAsync( var responseContent = string.Empty; try { - using var client = _httpClientFactory.CreateClient(); + var client = _httpClientFactory.CreateClient(); client.BaseAddress = new Uri(apiUrl); var request = new HttpRequestMessage(HttpMethod.Post, apiUrl);