From 564cfd5b405a195d94992ab96f33c53920aa7742 Mon Sep 17 00:00:00 2001 From: Rustam Khalimov Date: Sat, 12 Sep 2026 22:35:22 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B5=D1=80=D0=B5=D0=B2=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=B8=20FakeSecretCipher=20=D0=BD=D0=B0=20NSubstitute?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Хелпер Support/TestCiphers.New: детерминированная семантика enc:base64 через Returns-лямбды. Потребители (8 файлов) перетипизированы на ISecretCipher, фейк удалён, тесты 1340 зелёные. --- .../Api/TelegramKeysServiceTests.cs | 3 ++- .../Api/TgStatusServiceTests.cs | 9 +++---- .../Contracts/GrpcAiToolsTests.cs | 11 +++++---- .../Contracts/IntegrationsDiTests.cs | 2 +- .../Contracts/PipelineWorkerGrpcAiTests.cs | 3 ++- .../Support/GrpcAiClassifierTests.cs | 24 ++++++++++--------- .../Support/OperatorAuthHttpHost.cs | 2 +- .../Support/SettingsServiceTests.cs | 2 +- .../{FakeSecretCipher.cs => TestCiphers.cs} | 23 +++++++++++++----- 9 files changed, 48 insertions(+), 31 deletions(-) rename src/core/tests/Deal.Tests.Unit/Support/{FakeSecretCipher.cs => TestCiphers.cs} (51%) diff --git a/src/core/tests/Deal.Tests.Unit/Api/TelegramKeysServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Api/TelegramKeysServiceTests.cs index 8daca6e..9a3840f 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/TelegramKeysServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/TelegramKeysServiceTests.cs @@ -1,4 +1,5 @@ using Deal.Api.Telegram; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Support; @@ -11,7 +12,7 @@ namespace Deal.Tests.Unit.Api; public sealed class TelegramKeysServiceTests { private readonly TestGlobalSettingsStore _store = new(); - private readonly FakeSecretCipher _cipher = new(); + private readonly ISecretCipher _cipher = TestCiphers.New(); private readonly TelegramKeysService _service; public TelegramKeysServiceTests() diff --git a/src/core/tests/Deal.Tests.Unit/Api/TgStatusServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Api/TgStatusServiceTests.cs index 3e14b92..a8ad167 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/TgStatusServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/TgStatusServiceTests.cs @@ -1,5 +1,6 @@ using Deal.Api.Telegram; using Deal.Contracts.Integrations.Models; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; using Deal.Modules.Telegram.Application; using Deal.Modules.Telegram.Application.Models; @@ -104,13 +105,13 @@ public sealed class TgStatusServiceTests // ─── Хелперы ──────────────────────────────────────────────────────────── - // Собирает сервис на фейках (FakeTelegramGateway/Store/SettingsStore/GlobalSettingsStore + FakeSecretCipher). - private static (TgStatusService Service, TestTelegramStore Store, FakeSettingsStore Settings, TestTelegramGateway Gateway, FakeSecretCipher Cipher, TestGlobalSettingsStore GlobalSettings) Create() + // Собирает сервис на фейках (FakeTelegramGateway/Store/SettingsStore/GlobalSettingsStore + ISecretCipher). + private static (TgStatusService Service, TestTelegramStore Store, FakeSettingsStore Settings, TestTelegramGateway Gateway, ISecretCipher Cipher, TestGlobalSettingsStore GlobalSettings) Create() { var store = new TestTelegramStore(); var settings = new FakeSettingsStore(); var gateway = new TestTelegramGateway(); - var cipher = new FakeSecretCipher(); + var cipher = TestCiphers.New(); var globalSettings = new TestGlobalSettingsStore(); var keys = new TelegramKeysService(globalSettings.Store, cipher); var dialogs = new DialogsService(store.Store, settings, gateway.Gateway, NullLogger.Instance); @@ -127,7 +128,7 @@ public sealed class TgStatusServiceTests string apiId, string apiHash) { - string encrypted = new FakeSecretCipher().Encrypt(apiHash); + string encrypted = TestCiphers.New().Encrypt(apiHash); settings.Preload(GlobalSettingsKeys.TelegramKeys, $"{{\"apiId\":\"{apiId}\",\"apiHash\":\"{encrypted}\"}}"); } diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs index 2f79129..a042d0a 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/GrpcAiToolsTests.cs @@ -5,6 +5,7 @@ using Deal.Infrastructure.Integrations.Exceptions; using Deal.Infrastructure.Integrations.Models; using Deal.Infrastructure.Integrations.Options; using Deal.Infrastructure.Integrations.Services; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants.Models; @@ -39,7 +40,7 @@ public sealed class GrpcAiToolsTests reply.Keywords.Add("telegram каналы"); service.GenerateKeywordsReply = reply; FakeSettingsStore settings = new(); - FakeSecretCipher cipher = new(); + ISecretCipher cipher = TestCiphers.New(); FakeTenantLimitStore limits = new(); IAiTools tools = CreateTools(port, settings, cipher, limits); @@ -65,7 +66,7 @@ public sealed class GrpcAiToolsTests await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => { service.KeywordsUnavailable = true; - IAiTools tools = CreateTools(port, new FakeSettingsStore(), new FakeSecretCipher()); + IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New()); AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("описание", CancellationToken.None); @@ -88,7 +89,7 @@ public sealed class GrpcAiToolsTests Usage = new Usage { Prompt = 200, Completion = 10, Total = 210 }, }; FakeTenantLimitStore limits = new(); - IAiTools tools = CreateTools(port, new FakeSettingsStore(), new FakeSecretCipher(), limits); + IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New(), limits); AiEvaluateFitResultDto result = await tools.EvaluateFitAsync( "Ищу дизайнера для лендинга", "Разработка сайтов на Python", new[] { "python", "бэкенд" }, CancellationToken.None); @@ -112,7 +113,7 @@ public sealed class GrpcAiToolsTests await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => { service.FitUnavailable = true; - IAiTools tools = CreateTools(port, new FakeSettingsStore(), new FakeSecretCipher()); + IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New()); await Assert.ThrowsAsync( () => tools.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None)); @@ -128,7 +129,7 @@ public sealed class GrpcAiToolsTests private static GrpcAiTools CreateTools( int port, FakeSettingsStore settings, - FakeSecretCipher cipher, + ISecretCipher cipher, FakeTenantLimitStore? limits = null) { ITenantContext tenantContext = new TenantContext(); diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs index caf5547..ee77d65 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/IntegrationsDiTests.cs @@ -145,7 +145,7 @@ public sealed class IntegrationsDiTests tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N"))); services.AddSingleton(tenantContext); services.AddScoped(_ => new FakeSettingsStore()); - services.AddScoped(_ => new FakeSecretCipher()); + services.AddScoped(_ => TestCiphers.New()); services.AddScoped(_ => new FakeKanjStore()); services.AddScoped(_ => new FakeMlLearningStore()); services.AddScoped(_ => new FakeTenantLimitStore()); diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs index 4e6720a..c9b078f 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs @@ -9,6 +9,7 @@ using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Parse; using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Services; @@ -178,7 +179,7 @@ public sealed class PipelineWorkerGrpcAiTests var grpcClassifier = new GrpcAiClassifier( tenantContext, connection, - new AiProviderConfigBuilder(settings, new FakeSecretCipher()), + new AiProviderConfigBuilder(settings, TestCiphers.New()), new AiClassifyContextBuilder(settings, kanjStore), new TokenUsageRecorder(settings, limits, tenantContext, new TokenUsageEventService(new FakeTokenUsageEventStore())), NullLogger.Instance); diff --git a/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs b/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs index da7a260..3879639 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/GrpcAiClassifierTests.cs @@ -1,3 +1,4 @@ +using Deal.Tests.Unit.Support; using System.Text.Json; using System.Text.Json.Nodes; using Deal.Contracts.Integrations.Models; @@ -9,6 +10,7 @@ using Deal.Infrastructure.Integrations.Options; using Deal.Infrastructure.Integrations.Services; using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants.Models; @@ -50,7 +52,7 @@ public sealed class GrpcAiClassifierTests Reason = "реклама", Usage = new Usage { Prompt = 500, Completion = 40, Total = 540 }, }; - (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); + (FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port); settings.Preload(SettingsKeys.AiFilterPrompt, Json("Фильтруй. {domain} | {keywords}.")); settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain)); settings.Preload(SettingsKeys.DomainKeywords, Json(TestKeywords)); @@ -86,7 +88,7 @@ public sealed class GrpcAiClassifierTests await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => { service.FilterUnavailable = true; - (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); + (FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); await Assert.ThrowsAsync( @@ -100,7 +102,7 @@ public sealed class GrpcAiClassifierTests await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => { string text = new string('а', 5000); - (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); + (FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); await classifier.FilterAsync(text, CancellationToken.None); @@ -129,7 +131,7 @@ public sealed class GrpcAiClassifierTests """, Usage = new Usage { Prompt = 3000, Completion = 700, Total = 3700 }, }; - (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); + (FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port); settings.Preload(SettingsKeys.AiPrompt, Json("Разбор: {domain}.")); settings.Preload(SettingsKeys.CardPrompt, Json("Верни блок «О заявке».")); settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain)); @@ -189,7 +191,7 @@ public sealed class GrpcAiClassifierTests Ok = false, Usage = new Usage { Prompt = 900, Completion = 0, Total = 900 }, }; - (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); + (FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port); FakeTenantLimitStore limits = new(); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits); @@ -210,7 +212,7 @@ public sealed class GrpcAiClassifierTests await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => { service.ClassifyUnavailable = true; - (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); + (FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); await Assert.ThrowsAsync( @@ -224,7 +226,7 @@ public sealed class GrpcAiClassifierTests await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => { service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Заголовок","stack":[],"is_spam":false}""" }; - (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); + (FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port); settings.Preload(SettingsKeys.AiProvider, Json("anthropic")); settings.Preload( SettingsKeys.AiConfigs, @@ -261,7 +263,7 @@ public sealed class GrpcAiClassifierTests { service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Т","stack":[],"is_spam":false}""" }; string text = new string('б', 6000); - (FakeSettingsStore settings, FakeSecretCipher cipher, FakeKanjStore kanjStore) = Context(port); + (FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); await classifier.ClassifyAsync(text, CancellationToken.None); @@ -283,7 +285,7 @@ public sealed class GrpcAiClassifierTests private static GrpcAiClassifier CreateClassifier( int port, FakeSettingsStore settings, - FakeSecretCipher cipher, + ISecretCipher cipher, FakeKanjStore kanjStore, FakeTenantLimitStore? limits = null) { @@ -302,8 +304,8 @@ public sealed class GrpcAiClassifierTests // Создаёт контекст сценария (пустые фейки; сценарий переопределяет настройки/доски). // port: Порт хоста (не используется контекстом — единый вид хелперов). // Возвращает: Кортеж фейков (настройки, шифр, канбан). - private static (FakeSettingsStore Settings, FakeSecretCipher Cipher, FakeKanjStore Kanj) Context(int port) - => (new FakeSettingsStore(), new FakeSecretCipher(), new FakeKanjStore()); + private static (FakeSettingsStore Settings, ISecretCipher Cipher, FakeKanjStore Kanj) Context(int port) + => (new FakeSettingsStore(), TestCiphers.New(), new FakeKanjStore()); // Сериализует значение настройки в JSON-строку (как пишет SettingsStore). // value: Значение (строка/список). diff --git a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs index 9be025c..64c97f5 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/OperatorAuthHttpHost.cs @@ -193,7 +193,7 @@ internal static class OperatorAuthHttpHost builder.Services.AddSingleton(effectiveTenantStore); builder.Services.AddSingleton(effectiveLimitStore); builder.Services.AddSingleton(effectiveGlobalSettingsStore.Store); - builder.Services.AddSingleton(new FakeSecretCipher()); + builder.Services.AddSingleton(TestCiphers.New()); // Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys). builder.Services.AddScoped(); builder.Services.AddSingleton(effectiveTokenUsageStore); diff --git a/src/core/tests/Deal.Tests.Unit/Support/SettingsServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/SettingsServiceTests.cs index f4bb6de..680dd13 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/SettingsServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/SettingsServiceTests.cs @@ -13,7 +13,7 @@ namespace Deal.Tests.Unit.Support; public sealed class SettingsServiceTests { private readonly FakeSettingsStore _store = new(); - private readonly FakeSecretCipher _cipher = new(); + private readonly ISecretCipher _cipher = TestCiphers.New(); private readonly SettingsService _service; public SettingsServiceTests() diff --git a/src/core/tests/Deal.Tests.Unit/Support/FakeSecretCipher.cs b/src/core/tests/Deal.Tests.Unit/Support/TestCiphers.cs similarity index 51% rename from src/core/tests/Deal.Tests.Unit/Support/FakeSecretCipher.cs rename to src/core/tests/Deal.Tests.Unit/Support/TestCiphers.cs index 8e2399b..83df6ce 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/FakeSecretCipher.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TestCiphers.cs @@ -1,17 +1,29 @@ using System.Text; using Deal.Modules.Settings.Application.Abstractions; +using NSubstitute; namespace Deal.Tests.Unit.Support; /// -/// Детерминированный шифр для тестов SettingsService +/// Подставка с детерминированной семантикой «enc:base64» +/// (как раньше — фейк): шифрование/расшифровка взаимно обратимы в тестах. /// -public sealed class FakeSecretCipher : ISecretCipher +public static class TestCiphers { private const string EncryptedPrefix = "enc:"; - /// - public string Encrypt(string plainText) + /// + /// Создаёт подставку шифра. + /// + public static ISecretCipher New() + { + ISecretCipher cipher = Substitute.For(); + cipher.Encrypt(Arg.Any()).Returns(ci => Encrypt(ci.ArgAt(0))); + cipher.Decrypt(Arg.Any()).Returns(ci => Decrypt(ci.ArgAt(0))); + return cipher; + } + + private static string Encrypt(string plainText) { if (string.IsNullOrEmpty(plainText)) { @@ -21,8 +33,7 @@ public sealed class FakeSecretCipher : ISecretCipher return EncryptedPrefix + Convert.ToBase64String(Encoding.UTF8.GetBytes(plainText)); } - /// - public string Decrypt(string cipherText) + private static string Decrypt(string cipherText) { if (string.IsNullOrEmpty(cipherText)) {