Перевести FakeSecretCipher на NSubstitute
ci / build-test (push) Successful in 4m45s

Хелпер Support/TestCiphers.New: детерминированная семантика enc:base64 через
Returns-лямбды. Потребители (8 файлов) перетипизированы на ISecretCipher,
фейк удалён, тесты 1340 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-12 22:35:22 +03:00
parent f0cb807bff
commit 564cfd5b40
9 changed files with 48 additions and 31 deletions
@@ -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()
@@ -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<DialogsService>.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}\"}}");
}
@@ -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<AiUnavailableException>(
() => 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();
@@ -145,7 +145,7 @@ public sealed class IntegrationsDiTests
tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N")));
services.AddSingleton<ITenantContext>(tenantContext);
services.AddScoped<ISettingsStore>(_ => new FakeSettingsStore());
services.AddScoped<ISecretCipher>(_ => new FakeSecretCipher());
services.AddScoped<ISecretCipher>(_ => TestCiphers.New());
services.AddScoped<ICardStore>(_ => new FakeKanjStore());
services.AddScoped<IMlLearningStore>(_ => new FakeMlLearningStore());
services.AddScoped<ITenantLimitStore>(_ => new FakeTenantLimitStore());
@@ -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<GrpcAiClassifier>.Instance);
@@ -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<AiUnavailableException>(
@@ -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<AiUnavailableException>(
@@ -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: Значение (строка/список).
@@ -193,7 +193,7 @@ internal static class OperatorAuthHttpHost
builder.Services.AddSingleton<ITenantRepository>(effectiveTenantStore);
builder.Services.AddSingleton<ITenantLimitStore>(effectiveLimitStore);
builder.Services.AddSingleton<IGlobalSettingsStore>(effectiveGlobalSettingsStore.Store);
builder.Services.AddSingleton<ISecretCipher>(new FakeSecretCipher());
builder.Services.AddSingleton<ISecretCipher>(TestCiphers.New());
// Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys).
builder.Services.AddScoped<TelegramKeysService>();
builder.Services.AddSingleton<ITokenUsageEventStore>(effectiveTokenUsageStore);
@@ -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()
@@ -1,17 +1,29 @@
using System.Text;
using Deal.Modules.Settings.Application.Abstractions;
using NSubstitute;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Детерминированный шифр для тестов SettingsService
/// Подставка <see cref="ISecretCipher"/> с детерминированной семантикой «enc:base64»
/// (как раньше — фейк): шифрование/расшифровка взаимно обратимы в тестах.
/// </summary>
public sealed class FakeSecretCipher : ISecretCipher
public static class TestCiphers
{
private const string EncryptedPrefix = "enc:";
/// <inheritdoc />
public string Encrypt(string plainText)
/// <summary>
/// Создаёт подставку шифра.
/// </summary>
public static ISecretCipher New()
{
ISecretCipher cipher = Substitute.For<ISecretCipher>();
cipher.Encrypt(Arg.Any<string>()).Returns(ci => Encrypt(ci.ArgAt<string>(0)));
cipher.Decrypt(Arg.Any<string>()).Returns(ci => Decrypt(ci.ArgAt<string>(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));
}
/// <inheritdoc />
public string Decrypt(string cipherText)
private static string Decrypt(string cipherText)
{
if (string.IsNullOrEmpty(cipherText))
{