diff --git a/docs/spec/Код-стайл-Дейл.md b/docs/spec/Код-стайл-Дейл.md index 620a97f..5296b09 100644 --- a/docs/spec/Код-стайл-Дейл.md +++ b/docs/spec/Код-стайл-Дейл.md @@ -103,6 +103,25 @@ - Асинхронность: суффикс `Async`, `CancellationToken` пробрасывать до конца; `.Result` / `.Wait()` запрещены — только `await`. +### 4.1. Фабрики и билдеры + +- **Нетривиальные объекты с интерфейсом создаются только фабриками.** Реализация сервиса/адаптера, + у которого есть порт-интерфейс, не создаётся прямым `new` в прикладном коде или композиционном корне — + только внутри фабрики. Форма пары: `IXxxFactory` (порт фабрики) + `XxxFactory` (реализация), метод + `Create(...)` возвращает **интерфейс** готового объекта (`ISecretCipher`, `IFileStorage`, …). +- Фабрика сама регистрируется в DI (`AddScoped`/`AddSingleton()`) — контейнер + конструирует её без `new`; зависимости фабрики — тоже DI. +- **Билдер** (`IXxxBuilder`/`XxxBuilder`) добавляется, когда объект собирается итеративно из многих частей + или опций; фабрика делегирует сборку билдеру, а не повторяет её. +- Исключения из правила (прямой `new` допустим): + - DTO, рекорды, value-объекты, `Options`/`Settings`-снимки; + - исключения (`*Exception`) и примитивы/BCL-типы (`StringBuilder`, `NpgsqlConnection`, `MinioClient`, …); + - статические классы и хэлперы без состояния (фабрику для них не заводим); + - EF-конфигурации (`IEntityTypeConfiguration`) — это метаданные модели, а не прикладные объекты; + - обёртки ресурсов без порт-интерфейса (gRPC-соединения с `IDisposable`); + - объекты без порт-интерфейса, создаваемые контейнером (`AddScoped()`). +- Тесты могут конструировать проверяемый тип прямым `new` — это часть самого теста, а не прикладного кода. + ## 5. Комментирование кода Все комментарии — на русском языке. @@ -123,6 +142,8 @@ - **``/``** — только если смысл не очевиден из имени/типа; не переписывать сигнатуру. - **`` — только блочный.** Открывающий `` и закрывающий `` — **каждый на своей строке**; запись в одну строку (`/// текст`) **не допускается**. **[изм.]** +- **Конструкторы не документируем** — ``/`` на них не нужны: назначение очевидно из + типа и сигнатуры. В частности, не документируем конструкторы классов, реализующих интерфейс. **[изм.]** Правильно: ```csharp @@ -229,9 +250,12 @@ ## 11. Интерфейсы -- **Не дублировать `` интерфейса в реализации.** Если член объявлен в интерфейсе с XML-doc, - в классе-реализации достаточно `/// ` (или вообще ничего, если doc наследуется настройкой). - Текст описания пишется **один раз** — у интерфейса. +- **В реализациях интерфейсов XML-doc не пишем вообще.** Если тип или член объявлен в интерфейсе, + класс-реализация не документируется: ни ``, ни `` (и ни `` на + конструкторе). Описание живёт **один раз** — в интерфейсе; реализации вызываются только через порт. + Под этот запрет попадает и сам класс-реализация (его `` тоже лишний — есть у интерфейса). +- **XML-doc уместен только там, где нет интерфейса:** public-типы/члены без порта (статика, константы, + extension-классы), `protected`-члены и DTO/модели. **[изм. 2026-09-13, решение владельца]** - **Явная реализация интерфейсов — по умолчанию** (`Task ICardStore.GetAsync(...)`). **[изм. 2026-09-11, решение владельца]** Классы напрямую не вызываются — только через интерфейсы; исключения: DTO/модели (напр. `Card` и семейство `I*Card`), хелперы, extension-классы. Весь прод-код уже переведён на явные diff --git a/src/core/Deal.Infrastructure/Integrations/Abstractions/IAiClassifierFactory.cs b/src/core/Deal.Infrastructure/Integrations/Abstractions/IAiClassifierFactory.cs new file mode 100644 index 0000000..d6646b0 --- /dev/null +++ b/src/core/Deal.Infrastructure/Integrations/Abstractions/IAiClassifierFactory.cs @@ -0,0 +1,15 @@ +using Deal.Contracts.Integrations.Abstractions; + +namespace Deal.Infrastructure.Integrations.Abstractions; + +/// +/// Фабрика ИИ-классификатора с бюджетным гейтом +/// +public interface IAiClassifierFactory +{ + /// + /// Создаёт ИИ-классификатор + /// + /// Готовый порт классификации. + public IAiClassifier Create(); +} diff --git a/src/core/Deal.Infrastructure/Integrations/Abstractions/IAiToolsFactory.cs b/src/core/Deal.Infrastructure/Integrations/Abstractions/IAiToolsFactory.cs new file mode 100644 index 0000000..0952945 --- /dev/null +++ b/src/core/Deal.Infrastructure/Integrations/Abstractions/IAiToolsFactory.cs @@ -0,0 +1,15 @@ +using Deal.Contracts.Integrations.Abstractions; + +namespace Deal.Infrastructure.Integrations.Abstractions; + +/// +/// Фабрика ИИ-инструментов с бюджетным гейтом +/// +public interface IAiToolsFactory +{ + /// + /// Создаёт ИИ-инструменты + /// + /// Готовый порт ИИ-инструментов. + public IAiTools Create(); +} diff --git a/src/core/Deal.Infrastructure/Integrations/Services/AiClassifierFactory.cs b/src/core/Deal.Infrastructure/Integrations/Services/AiClassifierFactory.cs new file mode 100644 index 0000000..0f952a8 --- /dev/null +++ b/src/core/Deal.Infrastructure/Integrations/Services/AiClassifierFactory.cs @@ -0,0 +1,38 @@ +using Deal.Contracts.Integrations.Abstractions; +using Deal.Infrastructure.Integrations.Abstractions; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.SharedKernel.Tenants.Abstractions; +using Microsoft.Extensions.Logging; + +namespace Deal.Infrastructure.Integrations.Services; + +public sealed class AiClassifierFactory : IAiClassifierFactory +{ + private readonly GrpcAiClassifier _paidClassifier; + private readonly LocalAiClassifier _localClassifier; + private readonly ITenantLimitStore _tenantLimits; + private readonly ITenantContext _tenantContext; + private readonly ILogger _logger; + + public AiClassifierFactory( + GrpcAiClassifier paidClassifier, + LocalAiClassifier localClassifier, + ITenantLimitStore tenantLimits, + ITenantContext tenantContext, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(paidClassifier); + ArgumentNullException.ThrowIfNull(localClassifier); + ArgumentNullException.ThrowIfNull(tenantLimits); + ArgumentNullException.ThrowIfNull(tenantContext); + ArgumentNullException.ThrowIfNull(logger); + _paidClassifier = paidClassifier; + _localClassifier = localClassifier; + _tenantLimits = tenantLimits; + _tenantContext = tenantContext; + _logger = logger; + } + + IAiClassifier IAiClassifierFactory.Create() => + new BudgetedAiClassifier(_paidClassifier, _localClassifier, _tenantLimits, _tenantContext, _logger); +} diff --git a/src/core/Deal.Infrastructure/Integrations/Services/AiToolsFactory.cs b/src/core/Deal.Infrastructure/Integrations/Services/AiToolsFactory.cs new file mode 100644 index 0000000..0676ace --- /dev/null +++ b/src/core/Deal.Infrastructure/Integrations/Services/AiToolsFactory.cs @@ -0,0 +1,33 @@ +using Deal.Contracts.Integrations.Abstractions; +using Deal.Infrastructure.Integrations.Abstractions; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.SharedKernel.Tenants.Abstractions; +using Microsoft.Extensions.Logging; + +namespace Deal.Infrastructure.Integrations.Services; + +public sealed class AiToolsFactory : IAiToolsFactory +{ + private readonly GrpcAiTools _paidTools; + private readonly ITenantLimitStore _tenantLimits; + private readonly ITenantContext _tenantContext; + private readonly ILogger _logger; + + public AiToolsFactory( + GrpcAiTools paidTools, + ITenantLimitStore tenantLimits, + ITenantContext tenantContext, + ILogger logger) + { + ArgumentNullException.ThrowIfNull(paidTools); + ArgumentNullException.ThrowIfNull(tenantLimits); + ArgumentNullException.ThrowIfNull(tenantContext); + ArgumentNullException.ThrowIfNull(logger); + _paidTools = paidTools; + _tenantLimits = tenantLimits; + _tenantContext = tenantContext; + _logger = logger; + } + + IAiTools IAiToolsFactory.Create() => new BudgetedAiTools(_paidTools, _tenantLimits, _tenantContext, _logger); +} diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Abstractions/IFileStorageFactory.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Abstractions/IFileStorageFactory.cs new file mode 100644 index 0000000..1760129 --- /dev/null +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Abstractions/IFileStorageFactory.cs @@ -0,0 +1,15 @@ +using Deal.Contracts.Integrations.Abstractions; + +namespace Deal.Infrastructure.Integrations.Storage.Abstractions; + +/// +/// Фабрика файлового хранилища вложений (Local или MinIO по конфигурации) +/// +public interface IFileStorageFactory +{ + /// + /// Создаёт файловое хранилище + /// + /// Готовый порт файлового хранилища. + public IFileStorage Create(); +} diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Options/LocalStorageRoot.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Options/LocalStorageRoot.cs new file mode 100644 index 0000000..363fc4b --- /dev/null +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Options/LocalStorageRoot.cs @@ -0,0 +1,7 @@ +namespace Deal.Infrastructure.Integrations.Storage.Options; + +/// +/// Абсолютный путь корня локального файлового хранилища +/// +/// Абсолютный путь каталога вложений. +public sealed record LocalStorageRoot(string Path); diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageFactory.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageFactory.cs new file mode 100644 index 0000000..eb15ae4 --- /dev/null +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageFactory.cs @@ -0,0 +1,32 @@ +using Deal.Contracts.Integrations.Abstractions; +using Deal.Infrastructure.Integrations.Storage.Abstractions; +using Deal.Infrastructure.Integrations.Storage.Extensions; +using Deal.Infrastructure.Integrations.Storage.Options; +using Microsoft.Extensions.Logging; + +namespace Deal.Infrastructure.Integrations.Storage.Services; + +public sealed class FileStorageFactory : IFileStorageFactory +{ + private readonly StorageOptions _options; + private readonly LocalStorageRoot _localRoot; + private readonly ILogger _minioLogger; + + public FileStorageFactory( + StorageOptions options, + LocalStorageRoot localRoot, + ILogger minioLogger) + { + ArgumentNullException.ThrowIfNull(options); + ArgumentNullException.ThrowIfNull(localRoot); + ArgumentNullException.ThrowIfNull(minioLogger); + _options = options; + _localRoot = localRoot; + _minioLogger = minioLogger; + } + + IFileStorage IFileStorageFactory.Create() => + _options.Minio.IsConfigured() + ? new MinioFileStorage(_options.Minio, _minioLogger) + : new LocalFileStorage(_localRoot.Path); +} diff --git a/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageRegistrar.cs b/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageRegistrar.cs index 007b84c..b993afb 100644 --- a/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageRegistrar.cs +++ b/src/core/Deal.Infrastructure/Integrations/Storage/Services/FileStorageRegistrar.cs @@ -1,4 +1,5 @@ using Deal.Contracts.Integrations.Abstractions; +using Deal.Infrastructure.Integrations.Storage.Abstractions; using Deal.Infrastructure.Integrations.Storage.Extensions; using Deal.Infrastructure.Integrations.Storage.Options; using Microsoft.Extensions.Configuration; @@ -48,16 +49,12 @@ public static class FileStorageRegistrar ArgumentNullException.ThrowIfNull(configuration); StorageOptions options = ReadOptions(configuration); - - if (options.Minio.IsConfigured()) - { - services.AddSingleton(serviceProvider => - new MinioFileStorage(options.Minio, serviceProvider.GetRequiredService>())); - return services; - } - string rootPath = ResolveLocalRoot(options.Local, contentRootPath); - services.AddSingleton(new LocalFileStorage(rootPath)); + services.AddSingleton(options); + services.AddSingleton(new LocalStorageRoot(rootPath)); + services.AddSingleton(); + services.AddSingleton(serviceProvider => + serviceProvider.GetRequiredService().Create()); return services; } diff --git a/src/core/Deal.Infrastructure/Persistence/Abstractions/ITenantLimitStoreFactory.cs b/src/core/Deal.Infrastructure/Persistence/Abstractions/ITenantLimitStoreFactory.cs new file mode 100644 index 0000000..f8fadbd --- /dev/null +++ b/src/core/Deal.Infrastructure/Persistence/Abstractions/ITenantLimitStoreFactory.cs @@ -0,0 +1,15 @@ +using Deal.Modules.Tenants.Application.Abstractions; + +namespace Deal.Infrastructure.Persistence.Abstractions; + +/// +/// Фабрика хранилища лимитов ИИ-бюджета +/// +public interface ITenantLimitStoreFactory +{ + /// + /// Создаёт хранилище лимитов + /// + /// Готовый порт лимитов тенанта. + public ITenantLimitStore Create(); +} diff --git a/src/core/Deal.Infrastructure/Persistence/Services/TenantLimitStoreFactory.cs b/src/core/Deal.Infrastructure/Persistence/Services/TenantLimitStoreFactory.cs new file mode 100644 index 0000000..3764624 --- /dev/null +++ b/src/core/Deal.Infrastructure/Persistence/Services/TenantLimitStoreFactory.cs @@ -0,0 +1,22 @@ +using Deal.Infrastructure.Persistence.Abstractions; +using Deal.Infrastructure.Persistence.Repositories; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; + +namespace Deal.Infrastructure.Persistence.Services; + +public sealed class TenantLimitStoreFactory : ITenantLimitStoreFactory +{ + private readonly DealDbContext _dbContext; + private readonly TokenLimitDefaults _defaults; + + public TenantLimitStoreFactory(DealDbContext dbContext, TokenLimitDefaults defaults) + { + ArgumentNullException.ThrowIfNull(dbContext); + ArgumentNullException.ThrowIfNull(defaults); + _dbContext = dbContext; + _defaults = defaults; + } + + ITenantLimitStore ITenantLimitStoreFactory.Create() => new TenantLimitStore(_dbContext, _defaults); +} diff --git a/src/core/Deal.Infrastructure/Security/Abstractions/ISecretCipherFactory.cs b/src/core/Deal.Infrastructure/Security/Abstractions/ISecretCipherFactory.cs new file mode 100644 index 0000000..e79b491 --- /dev/null +++ b/src/core/Deal.Infrastructure/Security/Abstractions/ISecretCipherFactory.cs @@ -0,0 +1,15 @@ +using Deal.Modules.Settings.Application.Abstractions; + +namespace Deal.Infrastructure.Security.Abstractions; + +/// +/// Фабрика шифра секретов тенанта +/// +public interface ISecretCipherFactory +{ + /// + /// Создаёт шифр секретов + /// + /// Готовый порт симметричного шифрования. + public ISecretCipher Create(); +} diff --git a/src/core/Deal.Infrastructure/Security/Services/SecretCipherFactory.cs b/src/core/Deal.Infrastructure/Security/Services/SecretCipherFactory.cs new file mode 100644 index 0000000..eabc786 --- /dev/null +++ b/src/core/Deal.Infrastructure/Security/Services/SecretCipherFactory.cs @@ -0,0 +1,17 @@ +using Deal.Infrastructure.Security.Abstractions; +using Deal.Modules.Settings.Application.Abstractions; + +namespace Deal.Infrastructure.Security.Services; + +public sealed class SecretCipherFactory : ISecretCipherFactory +{ + private readonly EncryptionKeyProvider _keyProvider; + + public SecretCipherFactory(EncryptionKeyProvider keyProvider) + { + ArgumentNullException.ThrowIfNull(keyProvider); + _keyProvider = keyProvider; + } + + ISecretCipher ISecretCipherFactory.Create() => new AesGcmSecretCipher(_keyProvider.GetKey()); +} diff --git a/src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs b/src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs index 84c5734..7d466c2 100644 --- a/src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs +++ b/src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs @@ -5,8 +5,12 @@ using Deal.Infrastructure.Integrations.Options; using Deal.Infrastructure.Integrations.Services; using Deal.Infrastructure.Integrations.Sources; using Deal.Infrastructure.Persistence; +using Deal.Infrastructure.Persistence.Abstractions; using Deal.Infrastructure.Persistence.Repositories; +using Deal.Infrastructure.Persistence.Services; using Deal.Infrastructure.Security; +using Deal.Infrastructure.Security.Abstractions; +using Deal.Infrastructure.Security.Services; using Deal.Infrastructure.Services; using Deal.Infrastructure.Tenancy; using Deal.Modules.Cards.Application.Abstractions; @@ -20,7 +24,6 @@ using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Models; using Deal.SharedKernel.Tenants.Abstractions; using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; namespace Deal.Infrastructure; @@ -46,9 +49,9 @@ public static class ServiceCollectionExtensions services.AddScoped(); - services.AddScoped(provider => new TenantLimitStore( - provider.GetRequiredService(), - tenantLimitDefaults ?? TokenBudgetDefaults.Default)); + services.AddSingleton(tenantLimitDefaults ?? TokenBudgetDefaults.Default); + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService().Create()); services.AddScoped(); @@ -117,18 +120,11 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); services.AddScoped(); - services.AddScoped(provider => new BudgetedAiClassifier( - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService>())); + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService().Create()); services.AddScoped(); - services.AddScoped(provider => new BudgetedAiTools( - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService>())); + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService().Create()); } if (telegramOptions.UseLocal) @@ -152,8 +148,9 @@ public static class ServiceCollectionExtensions public static IServiceCollection AddDealSecurity(this IServiceCollection services, string contentRootPath) { EncryptionKeyProvider keyProvider = new(contentRootPath); - byte[] key = keyProvider.GetKey(); - services.AddSingleton(new AesGcmSecretCipher(key)); + services.AddSingleton(keyProvider); + services.AddSingleton(); + services.AddSingleton(provider => provider.GetRequiredService().Create()); return services; } } diff --git a/src/core/Deal.Modules.Discovery/Application/Registrars/DiscoveryModuleRegistrar.cs b/src/core/Deal.Modules.Discovery/Application/Registrars/DiscoveryModuleRegistrar.cs index d87810f..848908b 100644 --- a/src/core/Deal.Modules.Discovery/Application/Registrars/DiscoveryModuleRegistrar.cs +++ b/src/core/Deal.Modules.Discovery/Application/Registrars/DiscoveryModuleRegistrar.cs @@ -21,8 +21,7 @@ public static class DiscoveryModuleRegistrar services.AddScoped(); services.AddScoped(); - services.AddSingleton(); - services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(); services.AddScoped(); services.AddScoped(); services.AddScoped(sp => new DiscoveryBanGuard( diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.cs index a5e66ea..13a9291 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.cs @@ -31,7 +31,7 @@ public sealed partial class DiscoveryWorkerService /// Квоты дня/flood/стоп-кран (DiscoveryBanGuard). /// Паузы между авто-вступлениями (интерфейс — фейк в тестах). /// Гейт telegram-service (Search/Info/ReadForEval/Join/SetMonitor/Backfill). - /// Singleton-счётчик ошибок ключей поиска; null — локальный (на инстанс воркера). + /// Singleton-счётчик ошибок ключей поиска. public DiscoveryWorkerService( IDiscoveryStore store, DiscoveryTasksService tasks, @@ -41,7 +41,7 @@ public sealed partial class DiscoveryWorkerService DiscoveryBanGuard banGuard, IDiscoveryPacer pacer, ITelegramGateway gateway, - IDiscoverySearchErrorCounter? searchErrors = null) + IDiscoverySearchErrorCounter searchErrors) { ArgumentNullException.ThrowIfNull(store); ArgumentNullException.ThrowIfNull(tasks); @@ -51,6 +51,7 @@ public sealed partial class DiscoveryWorkerService ArgumentNullException.ThrowIfNull(banGuard); ArgumentNullException.ThrowIfNull(pacer); ArgumentNullException.ThrowIfNull(gateway); + ArgumentNullException.ThrowIfNull(searchErrors); _store = store; _tasks = tasks; _candidates = candidates; @@ -59,7 +60,7 @@ public sealed partial class DiscoveryWorkerService _banGuard = banGuard; _pacer = pacer; _gateway = gateway; - _searchErrors = searchErrors ?? new DiscoverySearchErrorCounter(); + _searchErrors = searchErrors; } /// diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs index fea2453..ec6eb0a 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs @@ -443,7 +443,8 @@ public sealed class DiscoveryWorkerServiceTests var pacer = Substitute.For(); var gateway = new TestDiscoveryGateway(); var worker = new DiscoveryWorkerService( - store.Store, tasks, candidates, log, new DiscoveryEvaluator(settings.Store, ml.Client, ai), banGuard, pacer, gateway.Gateway); + store.Store, tasks, candidates, log, new DiscoveryEvaluator(settings.Store, ml.Client, ai), banGuard, pacer, gateway.Gateway, + new DiscoverySearchErrorCounter()); return new Fixture(store, settings, gateway, pacer, worker, tasks); } diff --git a/src/core/tests/Deal.Tests.Unit/Support/FileStorageFactoryTests.cs b/src/core/tests/Deal.Tests.Unit/Support/FileStorageFactoryTests.cs new file mode 100644 index 0000000..3f7068a --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Support/FileStorageFactoryTests.cs @@ -0,0 +1,50 @@ +using Deal.Contracts.Integrations.Abstractions; +using Deal.Infrastructure.Integrations.Storage.Abstractions; +using Deal.Infrastructure.Integrations.Storage.Options; +using Deal.Infrastructure.Integrations.Storage.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Tests.Unit.Support; + +/// +/// Тесты FileStorageFactory — выбор Local/MinIO по заполненности секции Storage:Minio. +/// +public sealed class FileStorageFactoryTests +{ + // Локальный режим: относительно пустая секция Minio → LocalFileStorage. + [Fact] + public void Create_MinioNotConfigured_ReturnsLocalFileStorage() + { + IFileStorageFactory factory = new FileStorageFactory( + new StorageOptions(), + new LocalStorageRoot("/tmp/deal-attachments"), + NullLogger.Instance); + + IFileStorage storage = factory.Create(); + + Assert.IsType(storage); + } + + // MinIO-режим: заполнены Endpoint/AccessKey/SecretKey → MinioFileStorage. + [Fact] + public void Create_MinioConfigured_ReturnsMinioFileStorage() + { + var options = new StorageOptions + { + Minio = new MinioStorageOptions + { + Endpoint = "localhost:9000", + AccessKey = "deal", + SecretKey = "deal-secret", + }, + }; + IFileStorageFactory factory = new FileStorageFactory( + options, + new LocalStorageRoot("/tmp/deal-attachments"), + NullLogger.Instance); + + IFileStorage storage = factory.Create(); + + Assert.IsType(storage); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressTestHost.cs b/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressTestHost.cs index 6548585..84075ae 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressTestHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TelegramIngressTestHost.cs @@ -18,6 +18,7 @@ using Grpc.Net.Client; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Diagnostics.HealthChecks; @@ -57,15 +58,17 @@ internal static class TelegramIngressTestHost Func scenario, RateLimitOptions? rateLimitOptions = null) { - string? originalToken = Environment.GetEnvironmentVariable(ServiceTokenEnvKey); - Environment.SetEnvironmentVariable(ServiceTokenEnvKey, serviceToken); - WebApplication? app = null; GrpcChannel? channel = null; try { int port = TestPort.Allocate(); WebApplicationBuilder builder = WebApplication.CreateBuilder(); + // Токен ингресса задаётся конфигурацией хоста (в приоритете над env) — без мутации процесса. + builder.Configuration.AddInMemoryCollection(new Dictionary + { + [ServiceTokenEnvKey] = serviceToken, + }); builder.WebHost.ConfigureKestrel(kestrel => kestrel.Listen(IPAddress.Loopback, port, listen => listen.Protocols = HttpProtocols.Http2)); @@ -131,8 +134,6 @@ internal static class TelegramIngressTestHost await app.StopAsync(); await app.DisposeAsync(); } - - Environment.SetEnvironmentVariable(ServiceTokenEnvKey, originalToken); } }