Перевести FakeSettingsStore на NSubstitute (42 файла потребителей)

Хелпер Support/TestSettingsStore: словарь настроек, GetAsync/GetAllAsync/SetAsync/
RemoveAsync читают состояние на момент вызова (GetAllAsync — ленивый, ключ для
снапшот-сервисов). DI получает .Store, тесты сохраняют Preload/GetStoredJson.
This commit is contained in:
2026-09-13 01:22:10 +03:00
parent 3ccbe4c9f5
commit d51d88ed65
44 changed files with 327 additions and 315 deletions
@@ -94,8 +94,8 @@ public sealed class DiscoveryWorkerSchedulerTests
var tenantContext = new TenantContext(); var tenantContext = new TenantContext();
var storeA = new TestDiscoveryStore(); var storeA = new TestDiscoveryStore();
var storeB = new TestDiscoveryStore(); var storeB = new TestDiscoveryStore();
var settingsA = new FakeSettingsStore(); var settingsA = new TestSettingsStore();
var settingsB = new FakeSettingsStore(); var settingsB = new TestSettingsStore();
var gatewayA = new TestDiscoveryGateway(); var gatewayA = new TestDiscoveryGateway();
var gatewayB = new TestDiscoveryGateway(); var gatewayB = new TestDiscoveryGateway();
PreloadDiscoveryEvalOff(settingsA); PreloadDiscoveryEvalOff(settingsA);
@@ -109,7 +109,7 @@ public sealed class DiscoveryWorkerSchedulerTests
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant // Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
// (эталон PipelineWorkerSchedulerTests/ConnectionStringProvider.ForTenant). // (эталон PipelineWorkerSchedulerTests/ConnectionStringProvider.ForTenant).
services.AddScoped<IDiscoveryStore>(provider => TenantOf(provider) == TenantA ? storeA.Store : storeB.Store); services.AddScoped<IDiscoveryStore>(provider => TenantOf(provider) == TenantA ? storeA.Store : storeB.Store);
services.AddScoped<ISettingsStore>(provider => TenantOf(provider) == TenantA ? settingsA : settingsB); services.AddScoped<ISettingsStore>(provider => TenantOf(provider) == TenantA ? settingsA.Store : settingsB.Store);
services.AddScoped<ITelegramGateway>(provider => TenantOf(provider) == TenantA ? gatewayA.Gateway : gatewayB.Gateway); services.AddScoped<ITelegramGateway>(provider => TenantOf(provider) == TenantA ? gatewayA.Gateway : gatewayB.Gateway);
// Реальные сервисы модуля Discovery — как AddDiscoveryModule в Program.cs: цикл резолвит их в tenant-scope. // Реальные сервисы модуля Discovery — как AddDiscoveryModule в Program.cs: цикл резолвит их в tenant-scope.
services.AddDiscoveryModule(); services.AddDiscoveryModule();
@@ -124,7 +124,7 @@ public sealed class DiscoveryWorkerSchedulerTests
} }
// ML/ИИ выключены в настройках тенанта (оценка содержания — эвристика по ключам). // ML/ИИ выключены в настройках тенанта (оценка содержания — эвристика по ключам).
private static void PreloadDiscoveryEvalOff(FakeSettingsStore settings) private static void PreloadDiscoveryEvalOff(TestSettingsStore settings)
{ {
settings.Preload(SettingsKeys.MlEnabled, "false"); settings.Preload(SettingsKeys.MlEnabled, "false");
settings.Preload(SettingsKeys.AiEnabled, "false"); settings.Preload(SettingsKeys.AiEnabled, "false");
@@ -163,7 +163,7 @@ public sealed class MlOutboxFlushSchedulerTests
services.AddScoped<IMlTrainClient>(provider => provider.GetRequiredService<GrpcMlClient>()); services.AddScoped<IMlTrainClient>(provider => provider.GetRequiredService<GrpcMlClient>());
// Tenant-scoped адаптеры: выбирают фейк по тому же ITenantContext, который планировщик заполняет SetTenant. // Tenant-scoped адаптеры: выбирают фейк по тому же ITenantContext, который планировщик заполняет SetTenant.
services.AddScoped<IMlLearningStore>(provider => storesByTenant[TenantOf(provider)]); services.AddScoped<IMlLearningStore>(provider => storesByTenant[TenantOf(provider)]);
services.AddScoped<ISettingsStore>(_ => new FakeSettingsStore()); services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store);
services.AddScoped<ITenantLimitStore>(_ => new TestTenantLimitStore().Store); services.AddScoped<ITenantLimitStore>(_ => new TestTenantLimitStore().Store);
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store)); services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
services.AddScoped<TokenUsageRecorder>(); services.AddScoped<TokenUsageRecorder>();
@@ -43,7 +43,7 @@ public sealed class TgStatusServiceTests
[Fact] [Fact]
public async Task GetAsync_GatewayUnavailable_ReturnsIdleFormWithKvAccountCountAndKeys() public async Task GetAsync_GatewayUnavailable_ReturnsIdleFormWithKvAccountCountAndKeys()
{ {
(TgStatusService service, TestTelegramStore store, FakeSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create(); (TgStatusService service, TestTelegramStore store, TestSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true)); store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true));
store.Seed(Dialog("d_2", "Группа", "group", Monitor: true)); store.Seed(Dialog("d_2", "Группа", "group", Monitor: true));
store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false)); store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false));
@@ -69,7 +69,7 @@ public sealed class TgStatusServiceTests
[Fact] [Fact]
public async Task GetAsync_ReadyGateway_ComposesLiveFieldsWithKvAccountMonitoredAndKeys() public async Task GetAsync_ReadyGateway_ComposesLiveFieldsWithKvAccountMonitoredAndKeys()
{ {
(TgStatusService service, TestTelegramStore store, FakeSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create(); (TgStatusService service, TestTelegramStore store, TestSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create();
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true)); store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true));
settings.Preload(SettingsKeys.TgAccount, "\"@realuser\""); settings.Preload(SettingsKeys.TgAccount, "\"@realuser\"");
PreloadKeys(globalSettings, "123456", "abcdefghijklmnop"); PreloadKeys(globalSettings, "123456", "abcdefghijklmnop");
@@ -106,16 +106,16 @@ public sealed class TgStatusServiceTests
// ─── Хелперы ──────────────────────────────────────────────────────────── // ─── Хелперы ────────────────────────────────────────────────────────────
// Собирает сервис на фейках (FakeTelegramGateway/Store/SettingsStore/GlobalSettingsStore + ISecretCipher). // Собирает сервис на фейках (FakeTelegramGateway/Store/SettingsStore/GlobalSettingsStore + ISecretCipher).
private static (TgStatusService Service, TestTelegramStore Store, FakeSettingsStore Settings, TestTelegramGateway Gateway, ISecretCipher Cipher, TestGlobalSettingsStore GlobalSettings) Create() private static (TgStatusService Service, TestTelegramStore Store, TestSettingsStore Settings, TestTelegramGateway Gateway, ISecretCipher Cipher, TestGlobalSettingsStore GlobalSettings) Create()
{ {
var store = new TestTelegramStore(); var store = new TestTelegramStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var gateway = new TestTelegramGateway(); var gateway = new TestTelegramGateway();
var cipher = TestCiphers.New(); var cipher = TestCiphers.New();
var globalSettings = new TestGlobalSettingsStore(); var globalSettings = new TestGlobalSettingsStore();
var keys = new TelegramKeysService(globalSettings.Store, cipher); var keys = new TelegramKeysService(globalSettings.Store, cipher);
var dialogs = new DialogsService(store.Store, settings, gateway.Gateway, NullLogger<DialogsService>.Instance); var dialogs = new DialogsService(store.Store, settings.Store, gateway.Gateway, NullLogger<DialogsService>.Instance);
var service = new TgStatusService(gateway.Gateway, dialogs, settings, keys); var service = new TgStatusService(gateway.Gateway, dialogs, settings.Store, keys);
return (service, store, settings, gateway, cipher, globalSettings); return (service, store, settings, gateway, cipher, globalSettings);
} }
@@ -128,7 +128,7 @@ public sealed class BudgetedAiClassifierTests
Context ctx = Create( Context ctx = Create(
limits => limits.Preload( limits => limits.Preload(
TenantGuid, budgetTokens: 0, TenantLimitPeriods.Month, PeriodStart, usedTokens: 0)); TenantGuid, budgetTokens: 0, TenantLimitPeriods.Month, PeriodStart, usedTokens: 0));
var standaloneLocal = new LocalAiClassifier(new LocalFieldsParser(ctx.Settings)); var standaloneLocal = new LocalAiClassifier(new LocalFieldsParser(ctx.Settings.Store));
const string text = "Ищем Python-разработчика на проект в команду\nБюджет: до 2к$"; const string text = "Ищем Python-разработчика на проект в команду\nБюджет: до 2к$";
AiParsedCardDto result = await ctx.Decorator.ClassifyAsync(text, CancellationToken.None); AiParsedCardDto result = await ctx.Decorator.ClassifyAsync(text, CancellationToken.None);
@@ -152,7 +152,7 @@ public sealed class BudgetedAiClassifierTests
private sealed record Context( private sealed record Context(
IAiClassifier Decorator, IAiClassifier Decorator,
TestAiClassifier Paid, TestAiClassifier Paid,
FakeSettingsStore Settings); TestSettingsStore Settings);
// Собирает контекст: платный фейк + реальный Local-классификатор + фейк лимитов и tenant-контекст // Собирает контекст: платный фейк + реальный Local-классификатор + фейк лимитов и tenant-контекст
// (зеркало регистрации AddDealIntegrations при UseLocal=false). // (зеркало регистрации AddDealIntegrations при UseLocal=false).
@@ -160,7 +160,7 @@ public sealed class BudgetedAiClassifierTests
// Возвращает: Контекст теста. // Возвращает: Контекст теста.
private static Context Create(Action<TestTenantLimitStore>? configure = null) private static Context Create(Action<TestTenantLimitStore>? configure = null)
{ {
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var limits = new TestTenantLimitStore(); var limits = new TestTenantLimitStore();
configure?.Invoke(limits); configure?.Invoke(limits);
ITenantContext tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
@@ -168,7 +168,7 @@ public sealed class BudgetedAiClassifierTests
var paid = new TestAiClassifier(); var paid = new TestAiClassifier();
IAiClassifier decorator = new BudgetedAiClassifier( IAiClassifier decorator = new BudgetedAiClassifier(
paid.Classifier, paid.Classifier,
new LocalAiClassifier(new LocalFieldsParser(settings)), new LocalAiClassifier(new LocalFieldsParser(settings.Store)),
limits.Store, limits.Store,
tenantContext, tenantContext,
NullLogger<BudgetedAiClassifier>.Instance); NullLogger<BudgetedAiClassifier>.Instance);
@@ -193,7 +193,7 @@ public sealed class CardComposerTests
[Fact] [Fact]
public async Task BuildAsync_ConversionDisabled_LeavesConvFieldsEmpty() public async Task BuildAsync_ConversionDisabled_LeavesConvFieldsEmpty()
{ {
(CardComposer composer, _, FakeSettingsStore settings) = Create(); (CardComposer composer, _, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.ConversionOn, "false"); // budget_to_target L348349 settings.Preload(SettingsKeys.ConversionOn, "false"); // budget_to_target L348349
CardSnapshot snapshot = await composer.BuildAsync( CardSnapshot snapshot = await composer.BuildAsync(
@@ -232,11 +232,11 @@ public sealed class CardComposerTests
// Создаёт контекст теста: пустые хранилища (дефолты: конверсия включена, курсы — мок). // Создаёт контекст теста: пустые хранилища (дефолты: конверсия включена, курсы — мок).
// Возвращает: Кортеж (композитор, канбан-хранилище, KV-настройки). // Возвращает: Кортеж (композитор, канбан-хранилище, KV-настройки).
private static (CardComposer Composer, FakeKanjStore Store, FakeSettingsStore Settings) Create() private static (CardComposer Composer, FakeKanjStore Store, TestSettingsStore Settings) Create()
{ {
var store = new FakeKanjStore(); var store = new FakeKanjStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
return (new CardComposer(store, settings), store, settings); return (new CardComposer(store, settings.Store), store, settings);
} }
// Разбор карточки со значениями по умолчанию (сценарий теста перекрывает нужные поля). // Разбор карточки со значениями по умолчанию (сценарий теста перекрывает нужные поля).
@@ -711,12 +711,12 @@ public sealed class CardsServiceTests
// ─── Хелперы ────────────────────────────────────────────────────────── // ─── Хелперы ──────────────────────────────────────────────────────────
private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings, TestMlClient Ml) Create() private static (CardsService Service, FakeKanjStore Store, TestSettingsStore Settings, TestMlClient Ml) Create()
{ {
var store = new FakeKanjStore(); var store = new FakeKanjStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var ml = new TestMlClient(); var ml = new TestMlClient();
return (new CardsService(store, settings, ml.Client, new TestFileStorage().Storage), store, settings, ml); return (new CardsService(store, settings.Store, ml.Client, new TestFileStorage().Storage), store, settings, ml);
} }
// Доска с правилами (ContainerRulesDto) либо без них. // Доска с правилами (ContainerRulesDto) либо без них.
@@ -38,7 +38,7 @@ public sealed class DialogsServiceTests
[Fact] [Fact]
public async Task Sync_AutoMonitorNewFalse_AddsNewDialogsUnmonitored() public async Task Sync_AutoMonitorNewFalse_AddsNewDialogsUnmonitored()
{ {
(DialogsService service, TestTelegramStore store, FakeSettingsStore settings, _) = Create(); (DialogsService service, TestTelegramStore store, TestSettingsStore settings, _) = Create();
settings.Preload(SettingsKeys.AutoMonitorNew, "false"); settings.Preload(SettingsKeys.AutoMonitorNew, "false");
int count = await service.SyncFromTelegramAsync(Catalog(Entry("d_1", "Канал", "channel")), CancellationToken.None); int count = await service.SyncFromTelegramAsync(Catalog(Entry("d_1", "Канал", "channel")), CancellationToken.None);
@@ -497,12 +497,12 @@ public sealed class DialogsServiceTests
// ─── Хелперы ──────────────────────────────────────────────────────────── // ─── Хелперы ────────────────────────────────────────────────────────────
// Собирает сервис на фейках (TestTelegramStore — семантика адаптера; настройки/гейт чистые). // Собирает сервис на фейках (TestTelegramStore — семантика адаптера; настройки/гейт чистые).
private static (DialogsService Service, TestTelegramStore Store, FakeSettingsStore Settings, TestTelegramGateway Gateway) Create() private static (DialogsService Service, TestTelegramStore Store, TestSettingsStore Settings, TestTelegramGateway Gateway) Create()
{ {
var store = new TestTelegramStore(); var store = new TestTelegramStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var gateway = new TestTelegramGateway(); var gateway = new TestTelegramGateway();
return (new DialogsService(store.Store, settings, gateway.Gateway, NullLogger<DialogsService>.Instance), store, settings, gateway); return (new DialogsService(store.Store, settings.Store, gateway.Gateway, NullLogger<DialogsService>.Instance), store, settings, gateway);
} }
// Строка каталога минимально (id/имя/kind + флаги/хвост каталога). // Строка каталога минимально (id/имя/kind + флаги/хвост каталога).
@@ -168,14 +168,14 @@ public sealed class DiscoveryEvaluatorTests
=> new(id, text, DateMs: 1_700_000_000_000 + id, topic, TopicTitle: null); => new(id, text, DateMs: 1_700_000_000_000 + id, topic, TopicTitle: null);
// Создаёт оценщик на фейках: флаги веток выставляются сценарием. // Создаёт оценщик на фейках: флаги веток выставляются сценарием.
private static (DiscoveryEvaluator Evaluator, FakeSettingsStore Settings, TestMlClient Ml, IAiTools Ai) Create(bool aiEnabled, bool mlEnabled) private static (DiscoveryEvaluator Evaluator, TestSettingsStore Settings, TestMlClient Ml, IAiTools Ai) Create(bool aiEnabled, bool mlEnabled)
{ {
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
settings.Preload(SettingsKeys.AiEnabled, aiEnabled ? "true" : "false"); settings.Preload(SettingsKeys.AiEnabled, aiEnabled ? "true" : "false");
settings.Preload(SettingsKeys.MlEnabled, mlEnabled ? "true" : "false"); settings.Preload(SettingsKeys.MlEnabled, mlEnabled ? "true" : "false");
var ml = new TestMlClient(); var ml = new TestMlClient();
var ai = TestAiTools.New(); var ai = TestAiTools.New();
return (new DiscoveryEvaluator(settings, ml.Client, ai), settings, ml, ai); return (new DiscoveryEvaluator(settings.Store, ml.Client, ai), settings, ml, ai);
} }
// Задача как строка DiscTasks (адаптер-форма; нужны только keywords/description/threshold). // Задача как строка DiscTasks (адаптер-форма; нужны только keywords/description/threshold).
@@ -419,7 +419,7 @@ public sealed class DiscoveryWorkerServiceTests
// Контекст теста: воркер на фейках + ссылки для проверок состояния. // Контекст теста: воркер на фейках + ссылки для проверок состояния.
private sealed record Fixture( private sealed record Fixture(
TestDiscoveryStore Store, TestDiscoveryStore Store,
FakeSettingsStore Settings, TestSettingsStore Settings,
TestDiscoveryGateway Gateway, TestDiscoveryGateway Gateway,
IDiscoveryPacer Pacer, IDiscoveryPacer Pacer,
DiscoveryWorkerService Worker, DiscoveryWorkerService Worker,
@@ -429,7 +429,7 @@ public sealed class DiscoveryWorkerServiceTests
private static Fixture Create() private static Fixture Create()
{ {
var store = new TestDiscoveryStore(); var store = new TestDiscoveryStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
settings.Preload(SettingsKeys.MlEnabled, "false"); settings.Preload(SettingsKeys.MlEnabled, "false");
settings.Preload(SettingsKeys.AiEnabled, "false"); settings.Preload(SettingsKeys.AiEnabled, "false");
var ml = new TestMlClient(); var ml = new TestMlClient();
@@ -437,13 +437,13 @@ public sealed class DiscoveryWorkerServiceTests
var log = new DiscoveryLogService(store.Store); var log = new DiscoveryLogService(store.Store);
var blacklist = new DiscoveryBlacklistService(store.Store); var blacklist = new DiscoveryBlacklistService(store.Store);
var candidates = new DiscoveryCandidatesService(store.Store, log, blacklist); var candidates = new DiscoveryCandidatesService(store.Store, log, blacklist);
var planGuard = new DiscoveryPlanGuard(store.Store, settings); var planGuard = new DiscoveryPlanGuard(store.Store, settings.Store);
var tasks = new DiscoveryTasksService(store.Store, planGuard, settings); var tasks = new DiscoveryTasksService(store.Store, planGuard, settings.Store);
var banGuard = new DiscoveryBanGuard(store.Store, settings); var banGuard = new DiscoveryBanGuard(store.Store, settings.Store);
var pacer = Substitute.For<IDiscoveryPacer>(); var pacer = Substitute.For<IDiscoveryPacer>();
var gateway = new TestDiscoveryGateway(); var gateway = new TestDiscoveryGateway();
var worker = new DiscoveryWorkerService( var worker = new DiscoveryWorkerService(
store.Store, tasks, candidates, log, new DiscoveryEvaluator(settings, ml.Client, ai), banGuard, pacer, gateway.Gateway); store.Store, tasks, candidates, log, new DiscoveryEvaluator(settings.Store, ml.Client, ai), banGuard, pacer, gateway.Gateway);
return new Fixture(store, settings, gateway, pacer, worker, tasks); return new Fixture(store, settings, gateway, pacer, worker, tasks);
} }
@@ -39,7 +39,7 @@ public sealed class GrpcAiToolsTests
reply.Keywords.Add("python разработка"); reply.Keywords.Add("python разработка");
reply.Keywords.Add("telegram каналы"); reply.Keywords.Add("telegram каналы");
service.GenerateKeywordsReply = reply; service.GenerateKeywordsReply = reply;
FakeSettingsStore settings = new(); TestSettingsStore settings = new();
ISecretCipher cipher = TestCiphers.New(); ISecretCipher cipher = TestCiphers.New();
TestTenantLimitStore limits = new(); TestTenantLimitStore limits = new();
@@ -66,7 +66,7 @@ public sealed class GrpcAiToolsTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{ {
service.KeywordsUnavailable = true; service.KeywordsUnavailable = true;
IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New()); IAiTools tools = CreateTools(port, new TestSettingsStore(), TestCiphers.New());
AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("описание", CancellationToken.None); AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("описание", CancellationToken.None);
@@ -89,7 +89,7 @@ public sealed class GrpcAiToolsTests
Usage = new Usage { Prompt = 200, Completion = 10, Total = 210 }, Usage = new Usage { Prompt = 200, Completion = 10, Total = 210 },
}; };
TestTenantLimitStore limits = new(); TestTenantLimitStore limits = new();
IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New(), limits); IAiTools tools = CreateTools(port, new TestSettingsStore(), TestCiphers.New(), limits);
AiEvaluateFitResultDto result = await tools.EvaluateFitAsync( AiEvaluateFitResultDto result = await tools.EvaluateFitAsync(
"Ищу дизайнера для лендинга", "Разработка сайтов на Python", new[] { "python", "бэкенд" }, CancellationToken.None); "Ищу дизайнера для лендинга", "Разработка сайтов на Python", new[] { "python", "бэкенд" }, CancellationToken.None);
@@ -113,7 +113,7 @@ public sealed class GrpcAiToolsTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{ {
service.FitUnavailable = true; service.FitUnavailable = true;
IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New()); IAiTools tools = CreateTools(port, new TestSettingsStore(), TestCiphers.New());
await Assert.ThrowsAsync<AiUnavailableException>( await Assert.ThrowsAsync<AiUnavailableException>(
() => tools.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None)); () => tools.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None));
@@ -128,7 +128,7 @@ public sealed class GrpcAiToolsTests
// Возвращает: Экземпляр GrpcAiTools. // Возвращает: Экземпляр GrpcAiTools.
private static GrpcAiTools CreateTools( private static GrpcAiTools CreateTools(
int port, int port,
FakeSettingsStore settings, TestSettingsStore settings,
ISecretCipher cipher, ISecretCipher cipher,
TestTenantLimitStore? limits = null) TestTenantLimitStore? limits = null)
{ {
@@ -139,8 +139,8 @@ public sealed class GrpcAiToolsTests
return new GrpcAiTools( return new GrpcAiTools(
tenantContext, tenantContext,
connection, connection,
new AiProviderConfigBuilder(settings, cipher), new AiProviderConfigBuilder(settings.Store, cipher),
new TokenUsageRecorder(settings, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiTools>.Instance); NullLogger<GrpcAiTools>.Instance);
} }
} }
@@ -110,7 +110,7 @@ public sealed class GrpcMlClientTests
Eval = new ModelEval { Count = 5, Correct = 4, Accuracy = 0.8 }, Eval = new ModelEval { Count = 5, Correct = 4, Accuracy = 0.8 },
}; };
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
settings.Preload(SettingsKeys.MlDecisions, "7"); settings.Preload(SettingsKeys.MlDecisions, "7");
settings.Preload(SettingsKeys.AiDecisions, "3"); settings.Preload(SettingsKeys.AiDecisions, "3");
var learning = new FakeMlLearningStore { LearningCount = 9 }; var learning = new FakeMlLearningStore { LearningCount = 9 };
@@ -309,21 +309,22 @@ public sealed class GrpcMlClientTests
// Возвращает: Экземпляр GrpcMlClient в tenant-контексте теста. // Возвращает: Экземпляр GrpcMlClient в tenant-контексте теста.
private static GrpcMlClient CreateClient( private static GrpcMlClient CreateClient(
int port, int port,
FakeSettingsStore? settings = null, TestSettingsStore? settings = null,
FakeMlLearningStore? learning = null, FakeMlLearningStore? learning = null,
MlStatusCache? cache = null) MlStatusCache? cache = null)
{ {
settings ??= new TestSettingsStore();
ITenantContext tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue)); tenantContext.SetTenant(new TenantId(TenantIdValue));
var options = new MlServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }; var options = new MlServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" };
return new GrpcMlClient( return new GrpcMlClient(
tenantContext, tenantContext,
settings ?? new FakeSettingsStore(), settings.Store,
learning ?? new FakeMlLearningStore(), learning ?? new FakeMlLearningStore(),
new MlGrpcConnection(options), new MlGrpcConnection(options),
cache ?? new MlStatusCache(), cache ?? new MlStatusCache(),
new TokenUsageRecorder( new TokenUsageRecorder(
settings ?? new FakeSettingsStore(), settings.Store,
new TestTenantLimitStore().Store, new TestTenantLimitStore().Store,
tenantContext, tenantContext,
new TokenUsageEventService(new TestTokenUsageEventStore().Store)), new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
@@ -144,7 +144,7 @@ public sealed class IntegrationsDiTests
ITenantContext tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N"))); tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N")));
services.AddSingleton<ITenantContext>(tenantContext); services.AddSingleton<ITenantContext>(tenantContext);
services.AddScoped<ISettingsStore>(_ => new FakeSettingsStore()); services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store);
services.AddScoped<ISecretCipher>(_ => TestCiphers.New()); services.AddScoped<ISecretCipher>(_ => TestCiphers.New());
services.AddScoped<ICardStore>(_ => new FakeKanjStore()); services.AddScoped<ICardStore>(_ => new FakeKanjStore());
services.AddScoped<IMlLearningStore>(_ => new FakeMlLearningStore()); services.AddScoped<IMlLearningStore>(_ => new FakeMlLearningStore());
@@ -2,6 +2,7 @@ using Deal.Contracts.Integrations.Models;
using Deal.Infrastructure.Integrations.Services; using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Pipeline.Application.Parse; using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Contracts; namespace Deal.Tests.Unit.Contracts;
@@ -13,10 +14,10 @@ public sealed class LocalAiClassifierTests
{ {
// Создаёт контекст теста: пустое KV-хранилище (дефолты маркеров) + адаптер. // Создаёт контекст теста: пустое KV-хранилище (дефолты маркеров) + адаптер.
// Возвращает: Кортеж (KV-настройки, классификатор). // Возвращает: Кортеж (KV-настройки, классификатор).
private static (FakeSettingsStore Settings, LocalAiClassifier Classifier) CreateContext() private static (TestSettingsStore Settings, LocalAiClassifier Classifier) CreateContext()
{ {
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var classifier = new LocalAiClassifier(new LocalFieldsParser(settings)); var classifier = new LocalAiClassifier(new LocalFieldsParser(settings.Store));
return (settings, classifier); return (settings, classifier);
} }
@@ -140,7 +141,7 @@ public sealed class LocalAiClassifierTests
[Fact] [Fact]
public async Task Classify_CustomHireMarkersOverride_AreUsed() public async Task Classify_CustomHireMarkersOverride_AreUsed()
{ {
(FakeSettingsStore settings, LocalAiClassifier classifier) = CreateContext(); (TestSettingsStore settings, LocalAiClassifier classifier) = CreateContext();
// Дефолтные маркеры заменены: «вакансия» больше не маркер найма, свой — маркер. // Дефолтные маркеры заменены: «вакансия» больше не маркер найма, свой — маркер.
settings.Preload(SettingsKeys.HireMarkers, "[\"нанимаем джунов\"]"); settings.Preload(SettingsKeys.HireMarkers, "[\"нанимаем джунов\"]");
@@ -88,7 +88,7 @@ public sealed class PipelineCardWriterTests
{ {
var store = new FakeKanjStore(); var store = new FakeKanjStore();
var pipelineStore = new FakePipelineStore(); var pipelineStore = new FakePipelineStore();
var composer = new CardComposer(store, new FakeSettingsStore()); var composer = new CardComposer(store, new TestSettingsStore().Store);
return (new PipelineCardWriter(store, pipelineStore, composer), store, pipelineStore); return (new PipelineCardWriter(store, pipelineStore, composer), store, pipelineStore);
} }
@@ -156,7 +156,7 @@ public sealed class PipelineWorkerGrpcAiTests
PipelineWorkerService Worker, PipelineWorkerService Worker,
TestPipelineStore PipelineStore, TestPipelineStore PipelineStore,
FakeKanjStore KanjStore, FakeKanjStore KanjStore,
FakeSettingsStore Settings, TestSettingsStore Settings,
TestTenantLimitStore Limits); TestTenantLimitStore Limits);
private static Context CreateContext( private static Context CreateContext(
@@ -164,12 +164,12 @@ public sealed class PipelineWorkerGrpcAiTests
TestTenantLimitStore? limits = null, TestTenantLimitStore? limits = null,
bool budgeted = false) bool budgeted = false)
{ {
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var pipelineStore = new TestPipelineStore(); var pipelineStore = new TestPipelineStore();
var kanjStore = new FakeKanjStore(); var kanjStore = new FakeKanjStore();
var mlClient = new TestMlClient { Predict = NotReadyPrediction() }; var mlClient = new TestMlClient { Predict = NotReadyPrediction() };
var rules = new IncomingRules(settings); var rules = new IncomingRules(settings.Store);
var fieldsParser = new LocalFieldsParser(settings); var fieldsParser = new LocalFieldsParser(settings.Store);
ITenantContext tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue)); tenantContext.SetTenant(new TenantId(TenantIdValue));
@@ -179,9 +179,9 @@ public sealed class PipelineWorkerGrpcAiTests
var grpcClassifier = new GrpcAiClassifier( var grpcClassifier = new GrpcAiClassifier(
tenantContext, tenantContext,
connection, connection,
new AiProviderConfigBuilder(settings, TestCiphers.New()), new AiProviderConfigBuilder(settings.Store, TestCiphers.New()),
new AiClassifyContextBuilder(settings, kanjStore), new AiClassifyContextBuilder(settings.Store, kanjStore),
new TokenUsageRecorder(settings, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiClassifier>.Instance); NullLogger<GrpcAiClassifier>.Instance);
IAiClassifier aiClassifier = budgeted IAiClassifier aiClassifier = budgeted
? new BudgetedAiClassifier( ? new BudgetedAiClassifier(
@@ -193,10 +193,10 @@ public sealed class PipelineWorkerGrpcAiTests
: grpcClassifier; : grpcClassifier;
var processing = new PipelineProcessingService(pipelineStore.Store, mlClient.Client, new PipelineIngestService(pipelineStore.Store)); var processing = new PipelineProcessingService(pipelineStore.Store, mlClient.Client, new PipelineIngestService(pipelineStore.Store));
var composer = new CardComposer(kanjStore, settings); var composer = new CardComposer(kanjStore, settings.Store);
var writer = new PipelineCardWriter(kanjStore, pipelineStore.Store, composer); var writer = new PipelineCardWriter(kanjStore, pipelineStore.Store, composer);
var worker = new PipelineWorkerService( var worker = new PipelineWorkerService(
pipelineStore.Store, settings, rules, kanjStore, mlClient.Client, aiClassifier, processing, writer, fieldsParser); pipelineStore.Store, settings.Store, rules, kanjStore, mlClient.Client, aiClassifier, processing, writer, fieldsParser);
return new Context(worker, pipelineStore, kanjStore, settings, limits); return new Context(worker, pipelineStore, kanjStore, settings, limits);
} }
@@ -82,7 +82,7 @@ public sealed class CardMoverTests
private static (CardMover Mover, FakeKanjStore Store) Create() private static (CardMover Mover, FakeKanjStore Store) Create()
{ {
var store = new FakeKanjStore(); var store = new FakeKanjStore();
var cardsService = new CardsService(store, new FakeSettingsStore(), new TestMlClient().Client, new TestFileStorage().Storage); var cardsService = new CardsService(store, new TestSettingsStore().Store, new TestMlClient().Client, new TestFileStorage().Storage);
return (new CardMover(cardsService), store); return (new CardMover(cardsService), store);
} }
} }
@@ -40,8 +40,8 @@ public sealed class DiscoveryBanGuardTests
// Вчерашние вступления в суточную квоту не входят: 49 сегодня → ещё можно. // Вчерашние вступления в суточную квоту не входят: 49 сегодня → ещё можно.
TestDiscoveryStore yesterdayStore = new(() => Now); TestDiscoveryStore yesterdayStore = new(() => Now);
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var secondGuard = new DiscoveryBanGuard(yesterdayStore.Store, settings, () => Now); var secondGuard = new DiscoveryBanGuard(yesterdayStore.Store, settings.Store, () => Now);
for (int i = 0; i < 49; i++) for (int i = 0; i < 49; i++)
{ {
yesterdayStore.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddMinutes(i)); yesterdayStore.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddMinutes(i));
@@ -67,7 +67,7 @@ public sealed class DiscoveryBanGuardTests
[Fact] [Fact]
public async Task NoteFlood_BlocksAutoJoinsUntilEndOfDay() public async Task NoteFlood_BlocksAutoJoinsUntilEndOfDay()
{ {
(DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create(); (DiscoveryBanGuard guard, _, TestSettingsStore settings) = Create();
await guard.NoteFloodAsync(CancellationToken.None); await guard.NoteFloodAsync(CancellationToken.None);
@@ -79,7 +79,7 @@ public sealed class DiscoveryBanGuardTests
[Fact] [Fact]
public async Task FloodYesterday_DoesNotBlockToday() public async Task FloodYesterday_DoesNotBlockToday()
{ {
(DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create(); (DiscoveryBanGuard guard, _, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.DiscFloodDay, TodayStart.AddDays(-1).ToUnixTimeMilliseconds().ToString()); settings.Preload(SettingsKeys.DiscFloodDay, TodayStart.AddDays(-1).ToUnixTimeMilliseconds().ToString());
Assert.False(await guard.FloodTodayAsync(CancellationToken.None)); Assert.False(await guard.FloodTodayAsync(CancellationToken.None));
@@ -89,7 +89,7 @@ public sealed class DiscoveryBanGuardTests
[Fact] [Fact]
public async Task GlobalPaused_BlocksAutoJoins() public async Task GlobalPaused_BlocksAutoJoins()
{ {
(DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create(); (DiscoveryBanGuard guard, _, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.DiscPaused, "true"); settings.Preload(SettingsKeys.DiscPaused, "true");
Assert.True(await guard.GlobalPausedAsync(CancellationToken.None)); Assert.True(await guard.GlobalPausedAsync(CancellationToken.None));
@@ -99,7 +99,7 @@ public sealed class DiscoveryBanGuardTests
[Fact] [Fact]
public async Task CustomJoinLimit_AppliedWhenSettingStored() public async Task CustomJoinLimit_AppliedWhenSettingStored()
{ {
(DiscoveryBanGuard guard, TestDiscoveryStore store, FakeSettingsStore settings) = Create(); (DiscoveryBanGuard guard, TestDiscoveryStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.DiscJoinLimit, "2"); settings.Preload(SettingsKeys.DiscJoinLimit, "2");
Assert.True(await guard.CanAutoJoinAsync(CancellationToken.None)); Assert.True(await guard.CanAutoJoinAsync(CancellationToken.None));
@@ -109,11 +109,11 @@ public sealed class DiscoveryBanGuardTests
} }
// Создаёт связку гард + фейки на фиксированных «часах» (store и гард смотрят одно время). // Создаёт связку гард + фейки на фиксированных «часах» (store и гард смотрят одно время).
private static (DiscoveryBanGuard Guard, TestDiscoveryStore Store, FakeSettingsStore Settings) Create() private static (DiscoveryBanGuard Guard, TestDiscoveryStore Store, TestSettingsStore Settings) Create()
{ {
var store = new TestDiscoveryStore(() => Now); var store = new TestDiscoveryStore(() => Now);
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var guard = new DiscoveryBanGuard(store.Store, settings, () => Now); var guard = new DiscoveryBanGuard(store.Store, settings.Store, () => Now);
return (guard, store, settings); return (guard, store, settings);
} }
} }
@@ -117,7 +117,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact] [Fact]
public async Task Create_CustomThresholdAndSample_Respected() public async Task Create_CustomThresholdAndSample_Respected()
{ {
(DiscoveryTasksService service, TestDiscoveryStore store, FakeSettingsStore settings) = Create(); (DiscoveryTasksService service, TestDiscoveryStore store, TestSettingsStore settings) = Create();
settings.Preload("discEvalThreshold", "60"); settings.Preload("discEvalThreshold", "60");
settings.Preload("discEvalSample", "20"); settings.Preload("discEvalSample", "20");
@@ -330,12 +330,12 @@ public sealed class DiscoveryTasksServiceTests
} }
// Создаёт связку сервис + фейки (пустые настройки → дефолты SettingsDefaults). // Создаёт связку сервис + фейки (пустые настройки → дефолты SettingsDefaults).
private static (DiscoveryTasksService Service, TestDiscoveryStore Store, FakeSettingsStore Settings) Create() private static (DiscoveryTasksService Service, TestDiscoveryStore Store, TestSettingsStore Settings) Create()
{ {
var store = new TestDiscoveryStore(); var store = new TestDiscoveryStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var planGuard = new DiscoveryPlanGuard(store.Store, settings); var planGuard = new DiscoveryPlanGuard(store.Store, settings.Store);
return (new DiscoveryTasksService(store.Store, planGuard, settings), store, settings); return (new DiscoveryTasksService(store.Store, planGuard, settings.Store), store, settings);
} }
// Draft задачи с дефолтными полями (name/planJoins переопределяются). // Draft задачи с дефолтными полями (name/planJoins переопределяются).
@@ -2,6 +2,7 @@ using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Settings.Application.Services; using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
@@ -24,11 +25,11 @@ public sealed class AiClassifyContextBuilderTests
[Fact] [Fact]
public async Task BuildFilterPrompt_FillsDomainAndKeywords() public async Task BuildFilterPrompt_FillsDomainAndKeywords()
{ {
FakeSettingsStore settings = Context(out FakeKanjStore kanj); TestSettingsStore settings = Context(out FakeKanjStore kanj);
settings.Preload(SettingsKeys.AiFilterPrompt, Json("Страж. Сфера: {domain}. Маркеры: {keywords}.")); settings.Preload(SettingsKeys.AiFilterPrompt, Json("Страж. Сфера: {domain}. Маркеры: {keywords}."));
settings.Preload(SettingsKeys.DomainDescription, Json(Domain)); settings.Preload(SettingsKeys.DomainDescription, Json(Domain));
settings.Preload(SettingsKeys.DomainKeywords, Json(Keywords)); settings.Preload(SettingsKeys.DomainKeywords, Json(Keywords));
var builder = new AiClassifyContextBuilder(settings, kanj); var builder = new AiClassifyContextBuilder(settings.Store, kanj);
string prompt = await builder.BuildFilterPromptAsync(CancellationToken.None); string prompt = await builder.BuildFilterPromptAsync(CancellationToken.None);
@@ -38,9 +39,9 @@ public sealed class AiClassifyContextBuilderTests
[Fact] [Fact]
public async Task BuildFilterPrompt_NoDomain_UsesFallbackTextAndHint() public async Task BuildFilterPrompt_NoDomain_UsesFallbackTextAndHint()
{ {
FakeSettingsStore settings = Context(out FakeKanjStore kanj); TestSettingsStore settings = Context(out FakeKanjStore kanj);
settings.Preload(SettingsKeys.AiFilterPrompt, Json("[{domain}] {keywords}")); settings.Preload(SettingsKeys.AiFilterPrompt, Json("[{domain}] {keywords}"));
var builder = new AiClassifyContextBuilder(settings, kanj); var builder = new AiClassifyContextBuilder(settings.Store, kanj);
string prompt = await builder.BuildFilterPromptAsync(CancellationToken.None); string prompt = await builder.BuildFilterPromptAsync(CancellationToken.None);
@@ -52,12 +53,12 @@ public sealed class AiClassifyContextBuilderTests
[Fact] [Fact]
public async Task BuildClassifySystemPrompt_AppendsCardPromptWhenSet() public async Task BuildClassifySystemPrompt_AppendsCardPromptWhenSet()
{ {
FakeSettingsStore settings = Context(out FakeKanjStore kanj); TestSettingsStore settings = Context(out FakeKanjStore kanj);
settings.Preload(SettingsKeys.AiPrompt, Json("Классифицируй {domain}.")); settings.Preload(SettingsKeys.AiPrompt, Json("Классифицируй {domain}."));
settings.Preload(SettingsKeys.CardPrompt, Json("Верни блок «О заявке» {keywords}.")); settings.Preload(SettingsKeys.CardPrompt, Json("Верни блок «О заявке» {keywords}."));
settings.Preload(SettingsKeys.DomainDescription, Json(Domain)); settings.Preload(SettingsKeys.DomainDescription, Json(Domain));
settings.Preload(SettingsKeys.DomainKeywords, Json(Keywords)); settings.Preload(SettingsKeys.DomainKeywords, Json(Keywords));
var builder = new AiClassifyContextBuilder(settings, kanj); var builder = new AiClassifyContextBuilder(settings.Store, kanj);
string prompt = await builder.BuildClassifySystemPromptAsync(CancellationToken.None); string prompt = await builder.BuildClassifySystemPromptAsync(CancellationToken.None);
@@ -69,8 +70,8 @@ public sealed class AiClassifyContextBuilderTests
[Fact] [Fact]
public async Task BuildClassifyUserContext_EmptyBoards_ShowsNoBoardsPhrase() public async Task BuildClassifyUserContext_EmptyBoards_ShowsNoBoardsPhrase()
{ {
FakeSettingsStore settings = Context(out FakeKanjStore kanj); TestSettingsStore settings = Context(out FakeKanjStore kanj);
var builder = new AiClassifyContextBuilder(settings, kanj); var builder = new AiClassifyContextBuilder(settings.Store, kanj);
string context = await builder.BuildClassifyUserContextAsync("Нужен разработчик", CancellationToken.None); string context = await builder.BuildClassifyUserContextAsync("Нужен разработчик", CancellationToken.None);
@@ -82,7 +83,7 @@ public sealed class AiClassifyContextBuilderTests
[Fact] [Fact]
public async Task BuildClassifyUserContext_ListsBoardsWithKeywordsAndRules() public async Task BuildClassifyUserContext_ListsBoardsWithKeywordsAndRules()
{ {
FakeSettingsStore settings = Context(out FakeKanjStore kanj); TestSettingsStore settings = Context(out FakeKanjStore kanj);
kanj.SeedBoard(new ContainerDto kanj.SeedBoard(new ContainerDto
{ {
Id = "b_py", Id = "b_py",
@@ -113,7 +114,7 @@ public sealed class AiClassifyContextBuilderTests
Order = 1, Order = 1,
}); });
kanj.SeedBoard(new ContainerDto { Id = "b_sug", Name = "Предложение", Suggested = true, Order = 2 }); kanj.SeedBoard(new ContainerDto { Id = "b_sug", Name = "Предложение", Suggested = true, Order = 2 });
var builder = new AiClassifyContextBuilder(settings, kanj); var builder = new AiClassifyContextBuilder(settings.Store, kanj);
string context = await builder.BuildClassifyUserContextAsync("Ищу Python-разработчика", CancellationToken.None); string context = await builder.BuildClassifyUserContextAsync("Ищу Python-разработчика", CancellationToken.None);
@@ -128,10 +129,10 @@ public sealed class AiClassifyContextBuilderTests
[Fact] [Fact]
public async Task BuildClassifyUserContext_IncludesMarkupExamplesNewestFirst() public async Task BuildClassifyUserContext_IncludesMarkupExamplesNewestFirst()
{ {
FakeSettingsStore settings = Context(out FakeKanjStore kanj); TestSettingsStore settings = Context(out FakeKanjStore kanj);
SeedCardWithMove(kanj, "l_1", "b_py", "Пример: middle python (старый)"); SeedCardWithMove(kanj, "l_1", "b_py", "Пример: middle python (старый)");
SeedCardWithMove(kanj, "l_2", "b_py", "Пример: senior go (свежий)"); SeedCardWithMove(kanj, "l_2", "b_py", "Пример: senior go (свежий)");
var builder = new AiClassifyContextBuilder(settings, kanj); var builder = new AiClassifyContextBuilder(settings.Store, kanj);
string context = await builder.BuildClassifyUserContextAsync("Ищу разработчика", CancellationToken.None); string context = await builder.BuildClassifyUserContextAsync("Ищу разработчика", CancellationToken.None);
@@ -144,8 +145,8 @@ public sealed class AiClassifyContextBuilderTests
[Fact] [Fact]
public async Task BuildClassifyUserContext_LongMessage_TruncatesTo5000CodePoints() public async Task BuildClassifyUserContext_LongMessage_TruncatesTo5000CodePoints()
{ {
FakeSettingsStore settings = Context(out FakeKanjStore kanj); TestSettingsStore settings = Context(out FakeKanjStore kanj);
var builder = new AiClassifyContextBuilder(settings, kanj); var builder = new AiClassifyContextBuilder(settings.Store, kanj);
string text = new string('д', 6000); string text = new string('д', 6000);
string context = await builder.BuildClassifyUserContextAsync(text, CancellationToken.None); string context = await builder.BuildClassifyUserContextAsync(text, CancellationToken.None);
@@ -158,10 +159,10 @@ public sealed class AiClassifyContextBuilderTests
// Создаёт контекст: пустое KV + пустой канбан-фейк. // Создаёт контекст: пустое KV + пустой канбан-фейк.
// kanj: Канбан-фейк (доски/журнал примеров). // kanj: Канбан-фейк (доски/журнал примеров).
// Возвращает: KV-хранилище тенанта. // Возвращает: KV-хранилище тенанта.
private static FakeSettingsStore Context(out FakeKanjStore kanj) private static TestSettingsStore Context(out FakeKanjStore kanj)
{ {
kanj = new FakeKanjStore(); kanj = new FakeKanjStore();
return new FakeSettingsStore(); return new TestSettingsStore();
} }
// Кладёт карточку с исходным текстом и запись журнала move на неё (источник few-shot-примера). // Кладёт карточку с исходным текстом и запись журнала move на неё (источник few-shot-примера).
@@ -186,16 +186,16 @@ public sealed class CardsServiceRemindersTests
// ─── Хелперы ────────────────────────────────────────────────────────── // ─── Хелперы ──────────────────────────────────────────────────────────
private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings) Create(bool remindersEnabled = true) private static (CardsService Service, FakeKanjStore Store, TestSettingsStore Settings) Create(bool remindersEnabled = true)
{ {
var store = new FakeKanjStore(); var store = new FakeKanjStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
if (!remindersEnabled) if (!remindersEnabled)
{ {
settings.Preload(SettingsKeys.RemindersEnabled, "false"); settings.Preload(SettingsKeys.RemindersEnabled, "false");
} }
return (new CardsService(store, settings, new TestMlClient().Client, new TestFileStorage().Storage), store, settings); return (new CardsService(store, settings.Store, new TestMlClient().Client, new TestFileStorage().Storage), store, settings);
} }
// Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем. // Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем.
@@ -24,8 +24,8 @@ public sealed class MlReviewServiceTests
TestMlClient ml, TestMlClient ml,
out CardsService cards) out CardsService cards)
{ {
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
cards = new CardsService(kanj, settings, ml.Client, new TestFileStorage().Storage); cards = new CardsService(kanj, settings.Store, ml.Client, new TestFileStorage().Storage);
var processing = new PipelineProcessingService(pipeline.Store, ml.Client, new PipelineIngestService(pipeline.Store)); var processing = new PipelineProcessingService(pipeline.Store, ml.Client, new PipelineIngestService(pipeline.Store));
return new MlReviewService(pipeline.Store, kanj, cards, processing, ml.Client); return new MlReviewService(pipeline.Store, kanj, cards, processing, ml.Client);
} }
@@ -1,6 +1,7 @@
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
@@ -47,7 +48,7 @@ public sealed class StorageTickServiceTests
[Fact] [Fact]
public async Task Tick_AutoArchiveSettingFalse_KeepsExpiredCardsInPlace() public async Task Tick_AutoArchiveSettingFalse_KeepsExpiredCardsInPlace()
{ {
(StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create(); (StorageTickService service, FakeKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.AutoArchive, "false"); settings.Preload(SettingsKeys.AutoArchive, "false");
store.SeedBoard(Board("b_1")); store.SeedBoard(Board("b_1"));
store.SeedCard(Card("l_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(30)))); store.SeedCard(Card("l_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(30))));
@@ -62,7 +63,7 @@ public sealed class StorageTickServiceTests
[Fact] [Fact]
public async Task Tick_ArchiveAfterDays30_ArchivesOnlyCardsOlderThanConfiguredMonth() public async Task Tick_ArchiveAfterDays30_ArchivesOnlyCardsOlderThanConfiguredMonth()
{ {
(StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create(); (StorageTickService service, FakeKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.ArchiveAfterDays, "30"); settings.Preload(SettingsKeys.ArchiveAfterDays, "30");
store.SeedBoard(Board("b_1")); store.SeedBoard(Board("b_1"));
store.SeedCard(Card("l_older_month", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(32)))); store.SeedCard(Card("l_older_month", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(32))));
@@ -78,7 +79,7 @@ public sealed class StorageTickServiceTests
[Fact] [Fact]
public async Task Tick_ArchiveAfterDays1_ArchivesTwoDayOldCard() public async Task Tick_ArchiveAfterDays1_ArchivesTwoDayOldCard()
{ {
(StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create(); (StorageTickService service, FakeKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.ArchiveAfterDays, "1"); settings.Preload(SettingsKeys.ArchiveAfterDays, "1");
store.SeedCard(Card("l_inbox_old", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromDays(2)))); store.SeedCard(Card("l_inbox_old", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromDays(2))));
@@ -129,7 +130,7 @@ public sealed class StorageTickServiceTests
[Fact] [Fact]
public async Task Tick_ArchiveClearDaysFromSettings_ControlsArchivePurgeBoundary() public async Task Tick_ArchiveClearDaysFromSettings_ControlsArchivePurgeBoundary()
{ {
(StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create(); (StorageTickService service, FakeKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.ArchiveClearDays, "30"); settings.Preload(SettingsKeys.ArchiveClearDays, "30");
DateTimeOffset now = DateTimeOffset.UtcNow; DateTimeOffset now = DateTimeOffset.UtcNow;
store.SeedCard(Card("l_expired", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(10)))); store.SeedCard(Card("l_expired", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(10))));
@@ -165,7 +166,7 @@ public sealed class StorageTickServiceTests
[Fact] [Fact]
public async Task Tick_TrashClearDaysFromSettings_ControlsTrashPurgeBoundary() public async Task Tick_TrashClearDaysFromSettings_ControlsTrashPurgeBoundary()
{ {
(StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create(); (StorageTickService service, FakeKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.TrashClearDays, "3"); settings.Preload(SettingsKeys.TrashClearDays, "3");
store.SeedCard(Card("l_old", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(5)))); // старше 3 дн. store.SeedCard(Card("l_old", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(5)))); // старше 3 дн.
store.SeedCard(Card("l_fresh", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(1)))); store.SeedCard(Card("l_fresh", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(1))));
@@ -194,11 +195,11 @@ public sealed class StorageTickServiceTests
// ─── Хелперы ──────────────────────────────────────────────────────────── // ─── Хелперы ────────────────────────────────────────────────────────────
private static (StorageTickService Service, FakeKanjStore Store, FakeSettingsStore Settings) Create() private static (StorageTickService Service, FakeKanjStore Store, TestSettingsStore Settings) Create()
{ {
var store = new FakeKanjStore(); var store = new FakeKanjStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
return (new StorageTickService(store, settings), store, settings); return (new StorageTickService(store, settings.Store), store, settings);
} }
// Доска-колонка минимально (id/имя достаточно для списка кандидатов фейка). // Доска-колонка минимально (id/имя достаточно для списка кандидатов фейка).
@@ -1,63 +0,0 @@
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
namespace Deal.Tests.Unit.Modules.Settings;
/// <summary>
/// In-memory реализация <see cref="ISettingsStore"/> для юнит-тестов SettingsService.
/// </summary>
public sealed class FakeSettingsStore : ISettingsStore
{
private readonly Dictionary<string, SettingValue> _rows = new(StringComparer.Ordinal);
public IReadOnlyCollection<string> Keys => _rows.Keys.ToList();
/// <summary>
/// Кладёт готовую строку
/// </summary>
/// <param name="key">Ключ настройки.</param>
/// <param name="valueJson">Значение, сериализованное в JSON.</param>
public void Preload(string key, string valueJson)
{
_rows[key] = new SettingValue(key, valueJson, DateTimeOffset.UtcNow);
}
/// <summary>
/// Возвращает JSON сохранённого значения или null, если ключа нет.
/// </summary>
/// <param name="key">Ключ настройки.</param>
/// <returns>JSON-строка значения или null.</returns>
public string? GetStoredJson(string key)
{
return _rows.TryGetValue(key, out SettingValue? row) ? row.ValueJson : null;
}
/// <inheritdoc />
public Task<SettingValue?> GetAsync(string key, CancellationToken ct)
{
return Task.FromResult(_rows.TryGetValue(key, out SettingValue? row) ? row : null);
}
/// <inheritdoc />
public Task<IReadOnlyCollection<SettingValue>> GetAllAsync(CancellationToken ct)
{
return Task.FromResult<IReadOnlyCollection<SettingValue>>(_rows.Values.ToList());
}
/// <inheritdoc />
public Task SetAsync(
string key,
string valueJson,
CancellationToken ct)
{
_rows[key] = new SettingValue(key, valueJson, DateTimeOffset.UtcNow);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task RemoveAsync(string key, CancellationToken ct)
{
_rows.Remove(key);
return Task.CompletedTask;
}
}
@@ -32,7 +32,7 @@ public sealed class AdminTickOrchestratorTests
AdminTickOrchestrator Orchestrator, AdminTickOrchestrator Orchestrator,
TestPipelineStore PipelineStore, TestPipelineStore PipelineStore,
FakeKanjStore KanjStore, FakeKanjStore KanjStore,
FakeSettingsStore Settings, TestSettingsStore Settings,
TestAiClassifier AiClassifier, TestAiClassifier AiClassifier,
PipelinePumpGate PumpGate, PipelinePumpGate PumpGate,
SseSubscription Subscription); SseSubscription Subscription);
@@ -202,7 +202,7 @@ public sealed class AdminTickOrchestratorTests
// Возвращает: Оркестратор, фейки и подписку канала тенанта. // Возвращает: Оркестратор, фейки и подписку канала тенанта.
private static Context CreateContext(bool withThrowingQueueRead = false, bool withThrowingReminderCheck = false) private static Context CreateContext(bool withThrowingQueueRead = false, bool withThrowingReminderCheck = false)
{ {
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
// При сбое чтения очереди подменяем хранилище целиком (тот же объект идёт во все сервисы и в контекст). // При сбое чтения очереди подменяем хранилище целиком (тот же объект идёт во все сервисы и в контекст).
TestPipelineStore store = new(); TestPipelineStore store = new();
if (withThrowingQueueRead) if (withThrowingQueueRead)
@@ -214,21 +214,21 @@ public sealed class AdminTickOrchestratorTests
var kanjStore = new FakeKanjStore(); var kanjStore = new FakeKanjStore();
var mlClient = new TestMlClient(); var mlClient = new TestMlClient();
var aiClassifier = new TestAiClassifier(); var aiClassifier = new TestAiClassifier();
var rules = new IncomingRules(settings); var rules = new IncomingRules(settings.Store);
var fieldsParser = new LocalFieldsParser(settings); var fieldsParser = new LocalFieldsParser(settings.Store);
var ingest = new PipelineIngestService(store.Store); var ingest = new PipelineIngestService(store.Store);
var processing = new PipelineProcessingService(store.Store, mlClient.Client, ingest); var processing = new PipelineProcessingService(store.Store, mlClient.Client, ingest);
var composer = new CardComposer(kanjStore, settings); var composer = new CardComposer(kanjStore, settings.Store);
var writer = new PipelineCardWriter(kanjStore, store.Store, composer); var writer = new PipelineCardWriter(kanjStore, store.Store, composer);
var worker = new PipelineWorkerService( var worker = new PipelineWorkerService(
store.Store, settings, rules, kanjStore, mlClient.Client, aiClassifier.Classifier, processing, writer, fieldsParser); store.Store, settings.Store, rules, kanjStore, mlClient.Client, aiClassifier.Classifier, processing, writer, fieldsParser);
var broker = new SseBroker(); var broker = new SseBroker();
var tickService = new StorageTickService(kanjStore, settings); var tickService = new StorageTickService(kanjStore, settings.Store);
var toastPublisher = new StorageToastPublisher(broker); var toastPublisher = new StorageToastPublisher(broker);
var pumpGate = new PipelinePumpGate(); var pumpGate = new PipelinePumpGate();
FakeKanjStore reminderStore = withThrowingReminderCheck ? new ThrowingDueKanjStore() : kanjStore; FakeKanjStore reminderStore = withThrowingReminderCheck ? new ThrowingDueKanjStore() : kanjStore;
var cardsService = new CardsService(reminderStore, settings, mlClient.Client, new TestFileStorage().Storage); var cardsService = new CardsService(reminderStore, settings.Store, mlClient.Client, new TestFileStorage().Storage);
var orchestrator = new AdminTickOrchestrator( var orchestrator = new AdminTickOrchestrator(
tickService, processing, worker, cardsService, toastPublisher, broker, pumpGate, tickService, processing, worker, cardsService, toastPublisher, broker, pumpGate,
NullLogger<AdminTickOrchestrator>.Instance); NullLogger<AdminTickOrchestrator>.Instance);
@@ -23,7 +23,7 @@ public sealed class CardReclassifierTests
private sealed record Context( private sealed record Context(
CardReclassifier Reclassifier, CardReclassifier Reclassifier,
FakeKanjStore Store, FakeKanjStore Store,
FakeSettingsStore Settings, TestSettingsStore Settings,
TestMlClient MlClient, TestMlClient MlClient,
TestAiClassifier AiClassifier, TestAiClassifier AiClassifier,
ReclassifyGate Gate); ReclassifyGate Gate);
@@ -32,16 +32,16 @@ public sealed class CardReclassifierTests
// Возвращает: Сервис и фейки (сценарий до-настраивает карточки/доски/настройки). // Возвращает: Сервис и фейки (сценарий до-настраивает карточки/доски/настройки).
private static Context CreateContext() private static Context CreateContext()
{ {
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var store = new FakeKanjStore(); var store = new FakeKanjStore();
var mlClient = new TestMlClient(); var mlClient = new TestMlClient();
var aiClassifier = new TestAiClassifier(); var aiClassifier = new TestAiClassifier();
var fieldsParser = new LocalFieldsParser(settings); var fieldsParser = new LocalFieldsParser(settings.Store);
var composer = new CardComposer(store, settings); var composer = new CardComposer(store, settings.Store);
var cardsService = new CardsService(store, settings, mlClient.Client, new TestFileStorage().Storage); var cardsService = new CardsService(store, settings.Store, mlClient.Client, new TestFileStorage().Storage);
var gate = new ReclassifyGate(); var gate = new ReclassifyGate();
var reclassifier = new CardReclassifier( var reclassifier = new CardReclassifier(
store, settings, aiClassifier.Classifier, fieldsParser, composer, cardsService, mlClient.Client, gate); store, settings.Store, aiClassifier.Classifier, fieldsParser, composer, cardsService, mlClient.Client, gate);
return new Context(reclassifier, store, settings, mlClient, aiClassifier, gate); return new Context(reclassifier, store, settings, mlClient, aiClassifier, gate);
} }
@@ -242,7 +242,7 @@ public sealed class CardsServiceFilesTests
{ {
var store = new FakeKanjStore(); var store = new FakeKanjStore();
var storage = new TestFileStorage(); var storage = new TestFileStorage();
return (new CardsService(store, new FakeSettingsStore(), new TestMlClient().Client, storage.Storage), store, storage); return (new CardsService(store, new TestSettingsStore().Store, new TestMlClient().Client, storage.Storage), store, storage);
} }
// Карточка с полями по умолчанию (planned, CreatedAtMs=1, UpdatedAtMs=1; Files пуст). // Карточка с полями по умолчанию (planned, CreatedAtMs=1, UpdatedAtMs=1; Files пуст).
@@ -531,12 +531,12 @@ public sealed class CardsServiceSelectedTests
// ─── Хелперы ────────────────────────────────────────────────────────── // ─── Хелперы ──────────────────────────────────────────────────────────
private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings, TestMlClient Ml) Create() private static (CardsService Service, FakeKanjStore Store, TestSettingsStore Settings, TestMlClient Ml) Create()
{ {
var store = new FakeKanjStore(); var store = new FakeKanjStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var ml = new TestMlClient(); var ml = new TestMlClient();
return (new CardsService(store, settings, ml.Client, new TestFileStorage().Storage), store, settings, ml); return (new CardsService(store, settings.Store, ml.Client, new TestFileStorage().Storage), store, settings, ml);
} }
// Тело PATCH из пар «ключ → значение» (presence = наличие пары; null — явный JSON-null). // Тело PATCH из пар «ключ → значение» (presence = наличие пары; null — явный JSON-null).
@@ -340,7 +340,7 @@ public sealed class ContainersServiceTests
[Fact] [Fact]
public async Task GetColState_ReturnsStoredColumnsAsIs() public async Task GetColState_ReturnsStoredColumnsAsIs()
{ {
(ContainersService service, _, FakeSettingsStore settings) = Create(); (ContainersService service, _, TestSettingsStore settings) = Create();
settings.Preload("colState", """{"inbox":{"collapsed":true},"trash":{"width":"lg"}}"""); settings.Preload("colState", """{"inbox":{"collapsed":true},"trash":{"width":"lg"}}""");
IReadOnlyDictionary<string, ColumnStateDto> state = await service.GetColStateAsync(CancellationToken.None); IReadOnlyDictionary<string, ColumnStateDto> state = await service.GetColStateAsync(CancellationToken.None);
@@ -353,7 +353,7 @@ public sealed class ContainersServiceTests
[Fact] [Fact]
public async Task PatchColState_NewColumn_WritesStateAndReturnsIt() public async Task PatchColState_NewColumn_WritesStateAndReturnsIt()
{ {
(ContainersService service, _, FakeSettingsStore settings) = Create(); (ContainersService service, _, TestSettingsStore settings) = Create();
ColumnStateDto result = await service.PatchColStateAsync( ColumnStateDto result = await service.PatchColStateAsync(
"inbox", new ColumnStateDto(Collapsed: true, Width: null), CancellationToken.None); "inbox", new ColumnStateDto(Collapsed: true, Width: null), CancellationToken.None);
@@ -368,7 +368,7 @@ public sealed class ContainersServiceTests
[Fact] [Fact]
public async Task PatchColState_ExistingColumn_MergesPatchIntoCurrent() public async Task PatchColState_ExistingColumn_MergesPatchIntoCurrent()
{ {
(ContainersService service, _, FakeSettingsStore settings) = Create(); (ContainersService service, _, TestSettingsStore settings) = Create();
settings.Preload("colState", """{"inbox":{"collapsed":true}}"""); settings.Preload("colState", """{"inbox":{"collapsed":true}}""");
ColumnStateDto result = await service.PatchColStateAsync( ColumnStateDto result = await service.PatchColStateAsync(
@@ -380,7 +380,7 @@ public sealed class ContainersServiceTests
[Fact] [Fact]
public async Task PatchColState_NullFields_KeepCurrentState() public async Task PatchColState_NullFields_KeepCurrentState()
{ {
(ContainersService service, _, FakeSettingsStore settings) = Create(); (ContainersService service, _, TestSettingsStore settings) = Create();
settings.Preload("colState", """{"archive":{"collapsed":true,"width":"sm"}}"""); settings.Preload("colState", """{"archive":{"collapsed":true,"width":"sm"}}""");
ColumnStateDto result = await service.PatchColStateAsync( ColumnStateDto result = await service.PatchColStateAsync(
@@ -392,7 +392,7 @@ public sealed class ContainersServiceTests
[Fact] [Fact]
public async Task PatchColState_EmptyPatch_KeepsOtherColumnsAndWritesEmptyEntry() public async Task PatchColState_EmptyPatch_KeepsOtherColumnsAndWritesEmptyEntry()
{ {
(ContainersService service, _, FakeSettingsStore settings) = Create(); (ContainersService service, _, TestSettingsStore settings) = Create();
settings.Preload("colState", """{"archive":{"collapsed":true}}"""); settings.Preload("colState", """{"archive":{"collapsed":true}}""");
await service.PatchColStateAsync("inbox", new ColumnStateDto(Collapsed: null, Width: null), CancellationToken.None); await service.PatchColStateAsync("inbox", new ColumnStateDto(Collapsed: null, Width: null), CancellationToken.None);
@@ -408,7 +408,7 @@ public sealed class ContainersServiceTests
[Fact] [Fact]
public async Task GetColState_CorruptJson_ReturnsEmpty() public async Task GetColState_CorruptJson_ReturnsEmpty()
{ {
(ContainersService service, _, FakeSettingsStore settings) = Create(); (ContainersService service, _, TestSettingsStore settings) = Create();
settings.Preload("colState", "{oops"); settings.Preload("colState", "{oops");
IReadOnlyDictionary<string, ColumnStateDto> state = await service.GetColStateAsync(CancellationToken.None); IReadOnlyDictionary<string, ColumnStateDto> state = await service.GetColStateAsync(CancellationToken.None);
@@ -418,11 +418,11 @@ public sealed class ContainersServiceTests
// ─── Хелперы ────────────────────────────────────────────────────────── // ─── Хелперы ──────────────────────────────────────────────────────────
private static (ContainersService Service, FakeKanjStore Store, FakeSettingsStore Settings) Create() private static (ContainersService Service, FakeKanjStore Store, TestSettingsStore Settings) Create()
{ {
var store = new FakeKanjStore(); var store = new FakeKanjStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
return (new ContainersService(store, settings), store, settings); return (new ContainersService(store, settings.Store), store, settings);
} }
// Патч только с нужными полями: остальные параметры null = «не менять». // Патч только с нужными полями: остальные параметры null = «не менять».
@@ -3,6 +3,7 @@ using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Settings.Application.Services; using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
@@ -19,7 +20,7 @@ public sealed class ConversionRecomputerTests
[Fact] [Fact]
public async Task Recompute_MockRates_ConvertsUsdBudgetToRub() public async Task Recompute_MockRates_ConvertsUsdBudgetToRub()
{ {
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create(); (ConversionRecomputer service, FakeKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values)); settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
store.SeedCard(Card("l_inbox", KanbanColumns.Inbox, from: 50, to: 200, cur: "USD")); store.SeedCard(Card("l_inbox", KanbanColumns.Inbox, from: 50, to: 200, cur: "USD"));
@@ -34,7 +35,7 @@ public sealed class ConversionRecomputerTests
[Fact] [Fact]
public async Task Recompute_TargetCurrencyFromSettings_ConvertsToConfiguredCurrency() public async Task Recompute_TargetCurrencyFromSettings_ConvertsToConfiguredCurrency()
{ {
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create(); (ConversionRecomputer service, FakeKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values)); settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
settings.Preload(SettingsKeys.TargetCurrency, "\"EUR\""); // PATCH пишет Upper (ApplyStringKey) settings.Preload(SettingsKeys.TargetCurrency, "\"EUR\""); // PATCH пишет Upper (ApplyStringKey)
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -49,7 +50,7 @@ public sealed class ConversionRecomputerTests
[Fact] [Fact]
public async Task Recompute_NoTargetCurrencySetting_DefaultsToRub() public async Task Recompute_NoTargetCurrencySetting_DefaultsToRub()
{ {
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create(); (ConversionRecomputer service, FakeKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values)); settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -62,7 +63,7 @@ public sealed class ConversionRecomputerTests
[Fact] [Fact]
public async Task Recompute_UsdtBudget_TreatedAsUsdRate() public async Task Recompute_UsdtBudget_TreatedAsUsdRate()
{ {
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create(); (ConversionRecomputer service, FakeKanjStore store, TestSettingsStore settings) = Create();
var rates = new Dictionary<string, double> { ["RUB"] = 1.0, ["USD"] = 100.0, ["USDT"] = 90.0 }; var rates = new Dictionary<string, double> { ["RUB"] = 1.0, ["USD"] = 100.0, ["USDT"] = 90.0 };
settings.Preload(SettingsKeys.RatesCache, CacheJson(rates)); settings.Preload(SettingsKeys.RatesCache, CacheJson(rates));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USDT")); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USDT"));
@@ -78,7 +79,7 @@ public sealed class ConversionRecomputerTests
[Fact] [Fact]
public async Task Recompute_BudgetWithoutUpperBound_UsesLowerBoundForConvTo() public async Task Recompute_BudgetWithoutUpperBound_UsesLowerBoundForConvTo()
{ {
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create(); (ConversionRecomputer service, FakeKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values)); settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: null, cur: "USD")); store.SeedCard(Card("l_board", "b_1", from: 100, to: null, cur: "USD"));
@@ -93,7 +94,7 @@ public sealed class ConversionRecomputerTests
[Fact] [Fact]
public async Task Recompute_ConversionOff_ReturnsZeroAndKeepsCardsUntouched() public async Task Recompute_ConversionOff_ReturnsZeroAndKeepsCardsUntouched()
{ {
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create(); (ConversionRecomputer service, FakeKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.ConversionOn, "false"); settings.Preload(SettingsKeys.ConversionOn, "false");
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values)); settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -109,7 +110,7 @@ public sealed class ConversionRecomputerTests
[Fact] [Fact]
public async Task Recompute_ExcludesArchiveAndTrashCards() public async Task Recompute_ExcludesArchiveAndTrashCards()
{ {
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create(); (ConversionRecomputer service, FakeKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values)); settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
store.SeedCard(Card("l_archive", KanbanColumns.Archive, from: 100, to: 100, cur: "USD")); store.SeedCard(Card("l_archive", KanbanColumns.Archive, from: 100, to: 100, cur: "USD"));
@@ -128,7 +129,7 @@ public sealed class ConversionRecomputerTests
[Fact] [Fact]
public async Task Recompute_MissingCurrencyInRates_SkipsCardKeepingOldConversion() public async Task Recompute_MissingCurrencyInRates_SkipsCardKeepingOldConversion()
{ {
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create(); (ConversionRecomputer service, FakeKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, CacheJson(new Dictionary<string, double> { ["RUB"] = 1.0, ["USD"] = 92.5 })); settings.Preload(SettingsKeys.RatesCache, CacheJson(new Dictionary<string, double> { ["RUB"] = 1.0, ["USD"] = 92.5 }));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "XXX") with store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "XXX") with
{ {
@@ -159,7 +160,7 @@ public sealed class ConversionRecomputerTests
[Fact] [Fact]
public async Task Recompute_CorruptedRatesCache_ReturnsZero() public async Task Recompute_CorruptedRatesCache_ReturnsZero()
{ {
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create(); (ConversionRecomputer service, FakeKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, "{это не JSON"); settings.Preload(SettingsKeys.RatesCache, "{это не JSON");
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -174,7 +175,7 @@ public sealed class ConversionRecomputerTests
[Fact] [Fact]
public async Task OnRatesChangedAsync_PerformsFullRecompute() public async Task OnRatesChangedAsync_PerformsFullRecompute()
{ {
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create(); (ConversionRecomputer service, FakeKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values)); settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -189,7 +190,7 @@ public sealed class ConversionRecomputerTests
[Fact] [Fact]
public async Task Recompute_SecondRun_RecomputesSameValues() public async Task Recompute_SecondRun_RecomputesSameValues()
{ {
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create(); (ConversionRecomputer service, FakeKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values)); settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD")); store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -203,11 +204,11 @@ public sealed class ConversionRecomputerTests
// ─── Хелперы ──────────────────────────────────────────────────────────── // ─── Хелперы ────────────────────────────────────────────────────────────
private static (ConversionRecomputer Service, FakeKanjStore Store, FakeSettingsStore Settings) Create() private static (ConversionRecomputer Service, FakeKanjStore Store, TestSettingsStore Settings) Create()
{ {
var store = new FakeKanjStore(); var store = new FakeKanjStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
return (new ConversionRecomputer(settings, store), store, settings); return (new ConversionRecomputer(settings.Store, store), store, settings);
} }
// Карточка с бюджетом в колонке (BudgetCur задан → Budget не null, как маппинг адаптера). // Карточка с бюджетом в колонке (BudgetCur задан → Budget не null, как маппинг адаптера).
@@ -52,7 +52,7 @@ public sealed class GrpcAiClassifierTests
Reason = "реклама", Reason = "реклама",
Usage = new Usage { Prompt = 500, Completion = 40, Total = 540 }, Usage = new Usage { Prompt = 500, Completion = 40, Total = 540 },
}; };
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port); (TestSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
settings.Preload(SettingsKeys.AiFilterPrompt, Json("Фильтруй. {domain} | {keywords}.")); settings.Preload(SettingsKeys.AiFilterPrompt, Json("Фильтруй. {domain} | {keywords}."));
settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain)); settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain));
settings.Preload(SettingsKeys.DomainKeywords, Json(TestKeywords)); settings.Preload(SettingsKeys.DomainKeywords, Json(TestKeywords));
@@ -88,7 +88,7 @@ public sealed class GrpcAiClassifierTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{ {
service.FilterUnavailable = true; service.FilterUnavailable = true;
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port); (TestSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
await Assert.ThrowsAsync<AiUnavailableException>( await Assert.ThrowsAsync<AiUnavailableException>(
@@ -102,7 +102,7 @@ public sealed class GrpcAiClassifierTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{ {
string text = new string('а', 5000); string text = new string('а', 5000);
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port); (TestSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
await classifier.FilterAsync(text, CancellationToken.None); await classifier.FilterAsync(text, CancellationToken.None);
@@ -131,7 +131,7 @@ public sealed class GrpcAiClassifierTests
""", """,
Usage = new Usage { Prompt = 3000, Completion = 700, Total = 3700 }, Usage = new Usage { Prompt = 3000, Completion = 700, Total = 3700 },
}; };
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port); (TestSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
settings.Preload(SettingsKeys.AiPrompt, Json("Разбор: {domain}.")); settings.Preload(SettingsKeys.AiPrompt, Json("Разбор: {domain}."));
settings.Preload(SettingsKeys.CardPrompt, Json("Верни блок «О заявке».")); settings.Preload(SettingsKeys.CardPrompt, Json("Верни блок «О заявке»."));
settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain)); settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain));
@@ -191,7 +191,7 @@ public sealed class GrpcAiClassifierTests
Ok = false, Ok = false,
Usage = new Usage { Prompt = 900, Completion = 0, Total = 900 }, Usage = new Usage { Prompt = 900, Completion = 0, Total = 900 },
}; };
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port); (TestSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
TestTenantLimitStore limits = new(); TestTenantLimitStore limits = new();
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits);
@@ -212,7 +212,7 @@ public sealed class GrpcAiClassifierTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{ {
service.ClassifyUnavailable = true; service.ClassifyUnavailable = true;
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port); (TestSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
await Assert.ThrowsAsync<AiUnavailableException>( await Assert.ThrowsAsync<AiUnavailableException>(
@@ -226,7 +226,7 @@ public sealed class GrpcAiClassifierTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) => await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{ {
service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Заголовок","stack":[],"is_spam":false}""" }; service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Заголовок","stack":[],"is_spam":false}""" };
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port); (TestSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
settings.Preload(SettingsKeys.AiProvider, Json("anthropic")); settings.Preload(SettingsKeys.AiProvider, Json("anthropic"));
settings.Preload( settings.Preload(
SettingsKeys.AiConfigs, SettingsKeys.AiConfigs,
@@ -263,7 +263,7 @@ public sealed class GrpcAiClassifierTests
{ {
service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Т","stack":[],"is_spam":false}""" }; service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Т","stack":[],"is_spam":false}""" };
string text = new string('б', 6000); string text = new string('б', 6000);
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port); (TestSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore); IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
await classifier.ClassifyAsync(text, CancellationToken.None); await classifier.ClassifyAsync(text, CancellationToken.None);
@@ -284,7 +284,7 @@ public sealed class GrpcAiClassifierTests
// Возвращает: Экземпляр GrpcAiClassifier. // Возвращает: Экземпляр GrpcAiClassifier.
private static GrpcAiClassifier CreateClassifier( private static GrpcAiClassifier CreateClassifier(
int port, int port,
FakeSettingsStore settings, TestSettingsStore settings,
ISecretCipher cipher, ISecretCipher cipher,
FakeKanjStore kanjStore, FakeKanjStore kanjStore,
TestTenantLimitStore? limits = null) TestTenantLimitStore? limits = null)
@@ -296,17 +296,17 @@ public sealed class GrpcAiClassifierTests
return new GrpcAiClassifier( return new GrpcAiClassifier(
tenantContext, tenantContext,
connection, connection,
new AiProviderConfigBuilder(settings, cipher), new AiProviderConfigBuilder(settings.Store, cipher),
new AiClassifyContextBuilder(settings, kanjStore), new AiClassifyContextBuilder(settings.Store, kanjStore),
new TokenUsageRecorder(settings, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)), new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiClassifier>.Instance); NullLogger<GrpcAiClassifier>.Instance);
} }
// Создаёт контекст сценария (пустые фейки; сценарий переопределяет настройки/доски). // Создаёт контекст сценария (пустые фейки; сценарий переопределяет настройки/доски).
// port: Порт хоста (не используется контекстом — единый вид хелперов). // port: Порт хоста (не используется контекстом — единый вид хелперов).
// Возвращает: Кортеж фейков (настройки, шифр, канбан). // Возвращает: Кортеж фейков (настройки, шифр, канбан).
private static (FakeSettingsStore Settings, ISecretCipher Cipher, FakeKanjStore Kanj) Context(int port) private static (TestSettingsStore Settings, ISecretCipher Cipher, FakeKanjStore Kanj) Context(int port)
=> (new FakeSettingsStore(), TestCiphers.New(), new FakeKanjStore()); => (new TestSettingsStore(), TestCiphers.New(), new FakeKanjStore());
// Сериализует значение настройки в JSON-строку (как пишет SettingsStore). // Сериализует значение настройки в JSON-строку (как пишет SettingsStore).
// value: Значение (строка/список). // value: Значение (строка/список).
@@ -314,7 +314,7 @@ public sealed class GrpcAiClassifierTests
private static string Json(object value) => JsonSerializer.Serialize(value); private static string Json(object value) => JsonSerializer.Serialize(value);
private static async Task AssertUsageAsync( private static async Task AssertUsageAsync(
FakeSettingsStore settings, TestSettingsStore settings,
uint prompt, uint prompt,
uint completion, uint completion,
uint total) uint total)
@@ -1,5 +1,6 @@
using System.Text.Json; using System.Text.Json;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -12,7 +13,7 @@ public sealed class IncomingRulesTests
// Создаёт правила поверх in-memory KV-хранилища. // Создаёт правила поверх in-memory KV-хранилища.
// store: Хранилище настроек тенанта. // store: Хранилище настроек тенанта.
// Возвращает: Экземпляр IncomingRules. // Возвращает: Экземпляр IncomingRules.
private static IncomingRules CreateRules(FakeSettingsStore store) => new(store); private static IncomingRules CreateRules(TestSettingsStore store) => new(store.Store);
// Сериализует значение в JSON-строку строки settings (как пишет SettingsStore). // Сериализует значение в JSON-строку строки settings (как пишет SettingsStore).
// value: Значение (строка/число/булево/список). // value: Значение (строка/число/булево/список).
@@ -23,7 +24,7 @@ public sealed class IncomingRulesTests
[Fact] [Fact]
public async Task CheckAsync_ShortTextBelowDefaultMinLen_FailsWithKindLength() public async Task CheckAsync_ShortTextBelowDefaultMinLen_FailsWithKindLength()
{ {
IncomingRulesResult result = await CreateRules(new FakeSettingsStore()) IncomingRulesResult result = await CreateRules(new TestSettingsStore())
.CheckAsync("Короткий текст", CancellationToken.None); .CheckAsync("Короткий текст", CancellationToken.None);
Assert.False(result.Pass); Assert.False(result.Pass);
@@ -37,7 +38,7 @@ public sealed class IncomingRulesTests
public async Task CheckAsync_EmptyText_IsLengthFail() public async Task CheckAsync_EmptyText_IsLengthFail()
{ {
// (text or "").strip() — пустой/пробельный текст всегда «короче minLen». // (text or "").strip() — пустой/пробельный текст всегда «короче minLen».
IncomingRulesResult result = await CreateRules(new FakeSettingsStore()) IncomingRulesResult result = await CreateRules(new TestSettingsStore())
.CheckAsync(" ", CancellationToken.None); .CheckAsync(" ", CancellationToken.None);
Assert.False(result.Pass); Assert.False(result.Pass);
@@ -47,7 +48,7 @@ public sealed class IncomingRulesTests
[Fact] [Fact]
public async Task CheckAsync_StoredMinLenShorter_AllowsTextBetweenDefaultsAndOverride() public async Task CheckAsync_StoredMinLenShorter_AllowsTextBetweenDefaultsAndOverride()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.MinLen, Json(10)); store.Preload(SettingsKeys.MinLen, Json(10));
IncomingRules rules = CreateRules(store); IncomingRules rules = CreateRules(store);
@@ -61,7 +62,7 @@ public sealed class IncomingRulesTests
[Fact] [Fact]
public async Task CheckAsync_LongCleanTextWithDefaults_Passes() public async Task CheckAsync_LongCleanTextWithDefaults_Passes()
{ {
IncomingRulesResult result = await CreateRules(new FakeSettingsStore()).CheckAsync( IncomingRulesResult result = await CreateRules(new TestSettingsStore()).CheckAsync(
"Заработок на крипте 300% в месяц! Подпишись на канал и получи бесплатный курс по трейдингу", "Заработок на крипте 300% в месяц! Подпишись на канал и получи бесплатный курс по трейдингу",
CancellationToken.None); CancellationToken.None);
@@ -75,7 +76,7 @@ public sealed class IncomingRulesTests
[Fact] [Fact]
public async Task CheckAsync_StopPhraseHit_ReturnsPhraseAsKwAndReason() public async Task CheckAsync_StopPhraseHit_ReturnsPhraseAsKwAndReason()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(new[] { "взаимный пиар" })); store.Preload(SettingsKeys.StopPhrases, Json(new[] { "взаимный пиар" }));
IncomingRules rules = CreateRules(store); IncomingRules rules = CreateRules(store);
@@ -92,7 +93,7 @@ public sealed class IncomingRulesTests
[Fact] [Fact]
public async Task CheckAsync_StopPhrase_CaseInsensitiveMatch_KwKeepsStoredCase() public async Task CheckAsync_StopPhrase_CaseInsensitiveMatch_KwKeepsStoredCase()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(new[] { "Взаимный Пиар" })); store.Preload(SettingsKeys.StopPhrases, Json(new[] { "Взаимный Пиар" }));
IncomingRules rules = CreateRules(store); IncomingRules rules = CreateRules(store);
@@ -108,7 +109,7 @@ public sealed class IncomingRulesTests
[Fact] [Fact]
public async Task CheckAsync_ResumeMarker_BlockResumesOn_BlocksWithMarker() public async Task CheckAsync_ResumeMarker_BlockResumesOn_BlocksWithMarker()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>())); store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
IncomingRules rules = CreateRules(store); IncomingRules rules = CreateRules(store);
@@ -125,7 +126,7 @@ public sealed class IncomingRulesTests
[Fact] [Fact]
public async Task CheckAsync_ResumeText_BlockResumesOff_Passes() public async Task CheckAsync_ResumeText_BlockResumesOff_Passes()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>())); store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
store.Preload(SettingsKeys.BlockResumes, Json(false)); store.Preload(SettingsKeys.BlockResumes, Json(false));
IncomingRules rules = CreateRules(store); IncomingRules rules = CreateRules(store);
@@ -141,7 +142,7 @@ public sealed class IncomingRulesTests
[Fact] [Fact]
public async Task CheckAsync_ResumeWordWithoutHireMarkerBefore_IsCut() public async Task CheckAsync_ResumeWordWithoutHireMarkerBefore_IsCut()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>())); store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
IncomingRules rules = CreateRules(store); IncomingRules rules = CreateRules(store);
@@ -158,7 +159,7 @@ public sealed class IncomingRulesTests
[Fact] [Fact]
public async Task CheckAsync_VacancyWithResumeWordAfterHireMarker_GuardAllowsPass() public async Task CheckAsync_VacancyWithResumeWordAfterHireMarker_GuardAllowsPass()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>())); store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
IncomingRules rules = CreateRules(store); IncomingRules rules = CreateRules(store);
@@ -175,7 +176,7 @@ public sealed class IncomingRulesTests
[Fact] [Fact]
public async Task CheckAsync_WantedTypeFreelance_VacancyLikeText_BlockedAsType() public async Task CheckAsync_WantedTypeFreelance_VacancyLikeText_BlockedAsType()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>())); store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
store.Preload(SettingsKeys.WantedType, Json("freelance")); store.Preload(SettingsKeys.WantedType, Json("freelance"));
IncomingRules rules = CreateRules(store); IncomingRules rules = CreateRules(store);
@@ -194,7 +195,7 @@ public sealed class IncomingRulesTests
[Fact] [Fact]
public async Task CheckAsync_WantedTypeVacancy_OrderLikeText_BlockedAsType() public async Task CheckAsync_WantedTypeVacancy_OrderLikeText_BlockedAsType()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>())); store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
store.Preload(SettingsKeys.WantedType, Json("vacancy")); store.Preload(SettingsKeys.WantedType, Json("vacancy"));
IncomingRules rules = CreateRules(store); IncomingRules rules = CreateRules(store);
@@ -211,7 +212,7 @@ public sealed class IncomingRulesTests
[Fact] [Fact]
public async Task CheckAsync_WantedTypeFreelance_OrderText_Passes() public async Task CheckAsync_WantedTypeFreelance_OrderText_Passes()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>())); store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
store.Preload(SettingsKeys.WantedType, Json("freelance")); store.Preload(SettingsKeys.WantedType, Json("freelance"));
IncomingRules rules = CreateRules(store); IncomingRules rules = CreateRules(store);
@@ -229,7 +230,7 @@ public sealed class IncomingRulesTests
public async Task CheckAsync_WantedTypeBoth_VacancyText_Passes() public async Task CheckAsync_WantedTypeBoth_VacancyText_Passes()
{ {
// Дефолт wantedType=both: и вакансии, и заказы — вакансионный текст не отсекается. // Дефолт wantedType=both: и вакансии, и заказы — вакансионный текст не отсекается.
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>())); store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
IncomingRules rules = CreateRules(store); IncomingRules rules = CreateRules(store);
@@ -5,6 +5,7 @@ using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
@@ -17,11 +18,11 @@ public sealed class LocalColumnSuggesterTests
{ {
// Создаёт контекст теста: in-memory хранилища + адаптер поверх сервиса досок модуля. // Создаёт контекст теста: in-memory хранилища + адаптер поверх сервиса досок модуля.
// Возвращает: Кортеж (хранилище канбана, KV-настройки, адаптер). // Возвращает: Кортеж (хранилище канбана, KV-настройки, адаптер).
private static (FakeKanjStore Store, FakeSettingsStore Settings, LocalColumnSuggester Suggester) CreateContext() private static (FakeKanjStore Store, TestSettingsStore Settings, LocalColumnSuggester Suggester) CreateContext()
{ {
var store = new FakeKanjStore(); var store = new FakeKanjStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var suggester = new LocalColumnSuggester(store, settings, new ContainersService(store, settings)); var suggester = new LocalColumnSuggester(store, settings.Store, new ContainersService(store, settings.Store));
return (store, settings, suggester); return (store, settings, suggester);
} }
@@ -72,7 +73,7 @@ public sealed class LocalColumnSuggesterTests
[Fact] [Fact]
public async Task SuggestColumns_CooldownActive_ReturnsCooldownReason() public async Task SuggestColumns_CooldownActive_ReturnsCooldownReason()
{ {
(FakeKanjStore store, FakeSettingsStore settings, LocalColumnSuggester suggester) = CreateContext(); (FakeKanjStore store, TestSettingsStore settings, LocalColumnSuggester suggester) = CreateContext();
settings.Preload(SettingsKeys.LastSuggestAt, settings.Preload(SettingsKeys.LastSuggestAt,
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture)); DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
SeedInbox("l_1", "нужен python", store); SeedInbox("l_1", "нужен python", store);
@@ -128,7 +129,7 @@ public sealed class LocalColumnSuggesterTests
[Fact] [Fact]
public async Task SuggestColumns_Success_CreatesSuggestedBoardsAndPlacesCards() public async Task SuggestColumns_Success_CreatesSuggestedBoardsAndPlacesCards()
{ {
(FakeKanjStore store, FakeSettingsStore settings, LocalColumnSuggester suggester) = CreateContext(); (FakeKanjStore store, TestSettingsStore settings, LocalColumnSuggester suggester) = CreateContext();
SeedInbox("l_p1", "нужен python", store); SeedInbox("l_p1", "нужен python", store);
SeedInbox("l_p2", "нужен python", store); SeedInbox("l_p2", "нужен python", store);
SeedInbox("l_p3", "нужен python", store); SeedInbox("l_p3", "нужен python", store);
@@ -3,6 +3,7 @@ using Deal.Contracts.Integrations.Models;
using Deal.Infrastructure.Integrations.Services; using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Kanban; using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
@@ -27,14 +28,14 @@ public sealed class LocalMlClientTests
// store: Хранилище настроек тенанта. // store: Хранилище настроек тенанта.
// learning: Хранилище обучения (по умолчанию пустое: счётчики 0). // learning: Хранилище обучения (по умолчанию пустое: счётчики 0).
// Возвращает: Экземпляр LocalMlClient. // Возвращает: Экземпляр LocalMlClient.
private static LocalMlClient CreateClient(FakeSettingsStore store, FakeMlLearningStore? learning = null) private static LocalMlClient CreateClient(TestSettingsStore store, FakeMlLearningStore? learning = null)
=> new(store, learning ?? new FakeMlLearningStore()); => new(store.Store, learning ?? new FakeMlLearningStore());
[Fact] [Fact]
public async Task StatusAsync_NoSettings_ReturnsDeterministicNotReadyStatus() public async Task StatusAsync_NoSettings_ReturnsDeterministicNotReadyStatus()
{ {
LocalMlClient client = CreateClient(new FakeSettingsStore()); LocalMlClient client = CreateClient(new TestSettingsStore());
MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None); MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None);
@@ -63,7 +64,7 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task StatusAsync_StoredCounters_AreReflectedInStats() public async Task StatusAsync_StoredCounters_AreReflectedInStats()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlDecisions, "7"); store.Preload(SettingsKeys.MlDecisions, "7");
store.Preload(SettingsKeys.AiDecisions, "3"); store.Preload(SettingsKeys.AiDecisions, "3");
LocalMlClient client = CreateClient(store); LocalMlClient client = CreateClient(store);
@@ -78,7 +79,7 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task StatusAsync_StoredMlEnabledFalse_Disables() public async Task StatusAsync_StoredMlEnabledFalse_Disables()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlEnabled, "false"); store.Preload(SettingsKeys.MlEnabled, "false");
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None); MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
@@ -89,7 +90,7 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task StatusAsync_StoredMlEnabledTrue_Enables() public async Task StatusAsync_StoredMlEnabledTrue_Enables()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlEnabled, "true"); store.Preload(SettingsKeys.MlEnabled, "true");
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None); MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
@@ -102,7 +103,7 @@ public sealed class LocalMlClientTests
[InlineData("не json {{")] [InlineData("не json {{")]
public async Task StatusAsync_NonBoolOrDamagedMlEnabled_DegradesToDefault(string valueJson) public async Task StatusAsync_NonBoolOrDamagedMlEnabled_DegradesToDefault(string valueJson)
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlEnabled, valueJson); store.Preload(SettingsKeys.MlEnabled, valueJson);
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None); MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
@@ -113,7 +114,7 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task StatusAsync_DamagedCounter_DegradesToZero() public async Task StatusAsync_DamagedCounter_DegradesToZero()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlDecisions, "не число"); store.Preload(SettingsKeys.MlDecisions, "не число");
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None); MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
@@ -124,7 +125,7 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task StatusAsync_ReportsLearningAndOutboxFromTables() public async Task StatusAsync_ReportsLearningAndOutboxFromTables()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
var learning = new FakeMlLearningStore { LearningCount = 3 }; // журнал CardMoves: 3 записи var learning = new FakeMlLearningStore { LearningCount = 3 }; // журнал CardMoves: 3 записи
LocalMlClient client = CreateClient(store, learning); LocalMlClient client = CreateClient(store, learning);
await client.PushAsync("Разработчик Python, готов к проекту", "b_alpha", 1.0, CancellationToken.None); await client.PushAsync("Разработчик Python, готов к проекту", "b_alpha", 1.0, CancellationToken.None);
@@ -142,7 +143,7 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task PredictAsync_AnyText_ReturnsFixedNotReadyShape() public async Task PredictAsync_AnyText_ReturnsFixedNotReadyShape()
{ {
LocalMlClient client = CreateClient(new FakeSettingsStore()); LocalMlClient client = CreateClient(new TestSettingsStore());
MlPredictResultDto result = await client.PredictAsync( MlPredictResultDto result = await client.PredictAsync(
"Python backend на fastapi, бот в телеграм", CancellationToken.None); "Python backend на fastapi, бот в телеграм", CancellationToken.None);
@@ -162,7 +163,7 @@ public sealed class LocalMlClientTests
public async Task PushAsync_WritesOutboxRow_WithMleIdAndTrimmedFields() public async Task PushAsync_WritesOutboxRow_WithMleIdAndTrimmedFields()
{ {
var learning = new FakeMlLearningStore(); var learning = new FakeMlLearningStore();
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning); LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
await client.PushAsync(" Python-разработчик, готов к работе ", " b_alpha ", 1.0, CancellationToken.None); await client.PushAsync(" Python-разработчик, готов к работе ", " b_alpha ", 1.0, CancellationToken.None);
@@ -182,7 +183,7 @@ public sealed class LocalMlClientTests
public async Task PushAsync_EmptyTextOrLabelAfterTrim_IsNoOp(string text, string label) public async Task PushAsync_EmptyTextOrLabelAfterTrim_IsNoOp(string text, string label)
{ {
var learning = new FakeMlLearningStore(); var learning = new FakeMlLearningStore();
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning); LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
await client.PushAsync(text, label, 1.0, CancellationToken.None); await client.PushAsync(text, label, 1.0, CancellationToken.None);
@@ -193,7 +194,7 @@ public sealed class LocalMlClientTests
public async Task PushAsync_TextOver6000_IsTruncatedToMaxLength() public async Task PushAsync_TextOver6000_IsTruncatedToMaxLength()
{ {
var learning = new FakeMlLearningStore(); var learning = new FakeMlLearningStore();
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning); LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
string longText = new string('а', MaxLearningTextLength + 1000); string longText = new string('а', MaxLearningTextLength + 1000);
await client.PushAsync(longText, "spam", 1.0, CancellationToken.None); await client.PushAsync(longText, "spam", 1.0, CancellationToken.None);
@@ -207,7 +208,7 @@ public sealed class LocalMlClientTests
public async Task PushAsync_TruncationDoesNotSplitSurrogatePair() public async Task PushAsync_TruncationDoesNotSplitSurrogatePair()
{ {
var learning = new FakeMlLearningStore(); var learning = new FakeMlLearningStore();
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning); LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
// Длина 6001 в UTF-16: 5999 'x' + «🙂» (суррогатная пара) — срез .NET на 6000 попадает в пару. // Длина 6001 в UTF-16: 5999 'x' + «🙂» (суррогатная пара) — срез .NET на 6000 попадает в пару.
string text = new string('x', MaxLearningTextLength - 1) + "🙂"; string text = new string('x', MaxLearningTextLength - 1) + "🙂";
@@ -223,7 +224,7 @@ public sealed class LocalMlClientTests
public async Task PushAsync_NegativeDelta_IsStoredAsIs() public async Task PushAsync_NegativeDelta_IsStoredAsIs()
{ {
var learning = new FakeMlLearningStore(); var learning = new FakeMlLearningStore();
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning); LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
await client.PushAsync("Это не спам — вернул из корзины", "spam", -1.0, CancellationToken.None); await client.PushAsync("Это не спам — вернул из корзины", "spam", -1.0, CancellationToken.None);
@@ -236,7 +237,7 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task ResetAsync_ReturnsOk() public async Task ResetAsync_ReturnsOk()
{ {
MlResetResultDto result = await CreateClient(new FakeSettingsStore()).ResetAsync(CancellationToken.None); MlResetResultDto result = await CreateClient(new TestSettingsStore()).ResetAsync(CancellationToken.None);
Assert.True(result.Ok); Assert.True(result.Ok);
Assert.Null(result.Error); Assert.Null(result.Error);
@@ -245,7 +246,7 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task ResetAsync_DoesNotTouchSettingsRows() public async Task ResetAsync_DoesNotTouchSettingsRows()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlDecisions, "7"); store.Preload(SettingsKeys.MlDecisions, "7");
LocalMlClient client = CreateClient(store); LocalMlClient client = CreateClient(store);
@@ -259,7 +260,7 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task ResetAsync_ClearsOutbox_KeepsLearningJournalAndKvCounters() public async Task ResetAsync_ClearsOutbox_KeepsLearningJournalAndKvCounters()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlDecisions, "7"); store.Preload(SettingsKeys.MlDecisions, "7");
var learning = new FakeMlLearningStore { LearningCount = 4 }; // журнал CardMoves: 4 записи var learning = new FakeMlLearningStore { LearningCount = 4 }; // журнал CardMoves: 4 записи
LocalMlClient client = CreateClient(store, learning); LocalMlClient client = CreateClient(store, learning);
@@ -278,7 +279,7 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task MlStatusResponseDto_SerializesToPrototypeWireFormat() public async Task MlStatusResponseDto_SerializesToPrototypeWireFormat()
{ {
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlEnabled, "false"); store.Preload(SettingsKeys.MlEnabled, "false");
store.Preload(SettingsKeys.MlDecisions, "7"); store.Preload(SettingsKeys.MlDecisions, "7");
store.Preload(SettingsKeys.AiDecisions, "3"); store.Preload(SettingsKeys.AiDecisions, "3");
@@ -316,7 +317,7 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task MlPredictResultDto_SerializesToPrototypeWireFormat() public async Task MlPredictResultDto_SerializesToPrototypeWireFormat()
{ {
MlPredictResultDto dto = await CreateClient(new FakeSettingsStore()).PredictAsync("текст", CancellationToken.None); MlPredictResultDto dto = await CreateClient(new TestSettingsStore()).PredictAsync("текст", CancellationToken.None);
string json = JsonSerializer.Serialize(dto, CamelCaseOptions); string json = JsonSerializer.Serialize(dto, CamelCaseOptions);
@@ -329,7 +330,7 @@ public sealed class LocalMlClientTests
[Fact] [Fact]
public async Task MlResetResultDto_Success_SerializesWithoutErrorKey() public async Task MlResetResultDto_Success_SerializesWithoutErrorKey()
{ {
MlResetResultDto dto = await CreateClient(new FakeSettingsStore()).ResetAsync(CancellationToken.None); MlResetResultDto dto = await CreateClient(new TestSettingsStore()).ResetAsync(CancellationToken.None);
string json = JsonSerializer.Serialize(dto, CamelCaseOptions); string json = JsonSerializer.Serialize(dto, CamelCaseOptions);
@@ -3,6 +3,7 @@ using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Parse; using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support; namespace Deal.Tests.Unit.Support;
@@ -428,10 +429,10 @@ public sealed class MessageParseCoreTests
public async Task ParseAsync_ReadsMarkerOverridesFromSettings() public async Task ParseAsync_ReadsMarkerOverridesFromSettings()
{ {
// Кастомные hireMarkers из хранилища перекрывают дефолты: «вакансия» больше не маркер, свой — маркер. // Кастомные hireMarkers из хранилища перекрывают дефолты: «вакансия» больше не маркер, свой — маркер.
var store = new FakeSettingsStore(); var store = new TestSettingsStore();
store.Preload(SettingsKeys.HireMarkers, JsonSerializer.Serialize(new[] { "нанимаем джунов" })); store.Preload(SettingsKeys.HireMarkers, JsonSerializer.Serialize(new[] { "нанимаем джунов" }));
LocalFieldsParser parser = new(store); LocalFieldsParser parser = new(store.Store);
LocalParsedFields custom = await parser.ParseAsync("Срочно нанимаем джунов на проект", default); LocalParsedFields custom = await parser.ParseAsync("Срочно нанимаем джунов на проект", default);
LocalParsedFields withoutMarker = await parser.ParseAsync("Вакансия Java-разработчика на проект", default); LocalParsedFields withoutMarker = await parser.ParseAsync("Вакансия Java-разработчика на проект", default);
@@ -442,7 +443,7 @@ public sealed class MessageParseCoreTests
[Fact] [Fact]
public async Task ParseAsync_NoOverrides_UsesDefaults() public async Task ParseAsync_NoOverrides_UsesDefaults()
{ {
LocalFieldsParser parser = new(new FakeSettingsStore()); LocalFieldsParser parser = new(new TestSettingsStore().Store);
LocalParsedFields result = await parser.ParseAsync("Вакансия в команду на проект", default); LocalParsedFields result = await parser.ParseAsync("Вакансия в команду на проект", default);
@@ -155,8 +155,8 @@ public sealed class PipelineWorkerSchedulerTests
var pipelineB = new FakePipelineStore(); var pipelineB = new FakePipelineStore();
var kanjA = new FakeKanjStore(); var kanjA = new FakeKanjStore();
var kanjB = new FakeKanjStore(); var kanjB = new FakeKanjStore();
var settingsA = new FakeSettingsStore(); var settingsA = new TestSettingsStore();
var settingsB = new FakeSettingsStore(); var settingsB = new TestSettingsStore();
var pumpGate = new PipelinePumpGate(); var pumpGate = new PipelinePumpGate();
var aiClassifier = new TestAiClassifier var aiClassifier = new TestAiClassifier
{ {
@@ -173,7 +173,7 @@ public sealed class PipelineWorkerSchedulerTests
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant // Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
// (эталон StorageTickSchedulerTests/ConnectionStringProvider.ForTenant). // (эталон StorageTickSchedulerTests/ConnectionStringProvider.ForTenant).
services.AddScoped<ICardStore>(provider => TenantOf(provider) == TenantA ? kanjA : kanjB); services.AddScoped<ICardStore>(provider => TenantOf(provider) == TenantA ? kanjA : kanjB);
services.AddScoped<ISettingsStore>(provider => TenantOf(provider) == TenantA ? settingsA : settingsB); services.AddScoped<ISettingsStore>(provider => TenantOf(provider) == TenantA ? settingsA.Store : settingsB.Store);
services.AddScoped<IPipelineStore>(provider => TenantOf(provider) == TenantA ? pipelineA : pipelineB); services.AddScoped<IPipelineStore>(provider => TenantOf(provider) == TenantA ? pipelineA : pipelineB);
// Реальные сервисы модуля Pipeline — как AddPipelineModule в Program.cs: цикл резолвит их в tenant-scope. // Реальные сервисы модуля Pipeline — как AddPipelineModule в Program.cs: цикл резолвит их в tenant-scope.
services.AddScoped<IncomingRules>(); services.AddScoped<IncomingRules>();
@@ -27,7 +27,7 @@ public sealed class PipelineWorkerServiceTests
PipelineWorkerService Worker, PipelineWorkerService Worker,
TestPipelineStore PipelineStore, TestPipelineStore PipelineStore,
FakeKanjStore KanjStore, FakeKanjStore KanjStore,
FakeSettingsStore Settings, TestSettingsStore Settings,
TestMlClient MlClient, TestMlClient MlClient,
TestAiClassifier AiClassifier); TestAiClassifier AiClassifier);
@@ -36,18 +36,18 @@ public sealed class PipelineWorkerServiceTests
// Возвращает: Воркер и фейки (сценарий до-настраивает строки очереди/ответы портов). // Возвращает: Воркер и фейки (сценарий до-настраивает строки очереди/ответы портов).
private static Context CreateContext(TestPipelineStore? pipelineStore = null) private static Context CreateContext(TestPipelineStore? pipelineStore = null)
{ {
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var store = pipelineStore ?? new TestPipelineStore(); var store = pipelineStore ?? new TestPipelineStore();
var kanjStore = new FakeKanjStore(); var kanjStore = new FakeKanjStore();
var mlClient = new TestMlClient { Predict = NotReadyPrediction() }; var mlClient = new TestMlClient { Predict = NotReadyPrediction() };
var aiClassifier = new TestAiClassifier(); var aiClassifier = new TestAiClassifier();
var rules = new IncomingRules(settings); var rules = new IncomingRules(settings.Store);
var fieldsParser = new LocalFieldsParser(settings); var fieldsParser = new LocalFieldsParser(settings.Store);
var processing = new PipelineProcessingService(store.Store, mlClient.Client, new PipelineIngestService(store.Store)); var processing = new PipelineProcessingService(store.Store, mlClient.Client, new PipelineIngestService(store.Store));
var composer = new CardComposer(kanjStore, settings); var composer = new CardComposer(kanjStore, settings.Store);
var writer = new PipelineCardWriter(kanjStore, store.Store, composer); var writer = new PipelineCardWriter(kanjStore, store.Store, composer);
var worker = new PipelineWorkerService( var worker = new PipelineWorkerService(
store.Store, settings, rules, kanjStore, mlClient.Client, aiClassifier.Classifier, processing, writer, fieldsParser); store.Store, settings.Store, rules, kanjStore, mlClient.Client, aiClassifier.Classifier, processing, writer, fieldsParser);
return new Context(worker, store, kanjStore, settings, mlClient, aiClassifier); return new Context(worker, store, kanjStore, settings, mlClient, aiClassifier);
} }
@@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Settings.Application.Services; using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using NSubstitute; using NSubstitute;
@@ -14,7 +15,7 @@ public sealed class RatesServiceTests
{ {
private const long FixedTimestampMs = 1_752_000_000_000; private const long FixedTimestampMs = 1_752_000_000_000;
private readonly FakeSettingsStore _store = new(); private readonly TestSettingsStore _store = new();
// ─── GetAsync: кэш и дефолт ───────────────────────────────────────────── // ─── GetAsync: кэш и дефолт ─────────────────────────────────────────────
@@ -418,7 +419,7 @@ public sealed class RatesServiceTests
private RatesService CreateService(IRatesSource source, IReadOnlyList<IRatesChangedListener> listeners) private RatesService CreateService(IRatesSource source, IReadOnlyList<IRatesChangedListener> listeners)
{ {
return new RatesService(_store, source, listeners); return new RatesService(_store.Store, source, listeners);
} }
// JSON значения ratesCache с одним курсом USD и заданным временем/источником. // JSON значения ratesCache с одним курсом USD и заданным временем/источником.
@@ -1,6 +1,7 @@
using System.Text.Json; using System.Text.Json;
using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Settings.Application.Services; using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using NSubstitute; using NSubstitute;
@@ -12,13 +13,13 @@ namespace Deal.Tests.Unit.Support;
/// </summary> /// </summary>
public sealed class SettingsServiceTests public sealed class SettingsServiceTests
{ {
private readonly FakeSettingsStore _store = new(); private readonly TestSettingsStore _store = new();
private readonly ISecretCipher _cipher = TestCiphers.New(); private readonly ISecretCipher _cipher = TestCiphers.New();
private readonly SettingsService _service; private readonly SettingsService _service;
public SettingsServiceTests() public SettingsServiceTests()
{ {
_service = new SettingsService(_store, _cipher, Array.Empty<IRatesChangedListener>()); _service = new SettingsService(_store.Store, _cipher, Array.Empty<IRatesChangedListener>());
} }
// ─── GET: снимок ───────────────────────────────────────────────────────── // ─── GET: снимок ─────────────────────────────────────────────────────────
@@ -592,7 +593,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_TargetCurrencyInBody_NotifiesListenersAfterSave() public async Task ApplyPatchAsync_TargetCurrencyInBody_NotifiesListenersAfterSave()
{ {
var listener = Substitute.For<IRatesChangedListener>(); var listener = Substitute.For<IRatesChangedListener>();
SettingsService local = new(_store, _cipher, new[] { listener }); SettingsService local = new(_store.Store, _cipher, new[] { listener });
PublicSettingsDto snapshot = await local.ApplyPatchAsync(JsonBody(new { targetCurrency = "usd" }), CancellationToken.None); PublicSettingsDto snapshot = await local.ApplyPatchAsync(JsonBody(new { targetCurrency = "usd" }), CancellationToken.None);
@@ -605,7 +606,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_ConversionOnInBody_NotifiesListeners() public async Task ApplyPatchAsync_ConversionOnInBody_NotifiesListeners()
{ {
var listener = Substitute.For<IRatesChangedListener>(); var listener = Substitute.For<IRatesChangedListener>();
SettingsService local = new(_store, _cipher, new[] { listener }); SettingsService local = new(_store.Store, _cipher, new[] { listener });
await local.ApplyPatchAsync(JsonBody(new { conversionOn = false }), CancellationToken.None); await local.ApplyPatchAsync(JsonBody(new { conversionOn = false }), CancellationToken.None);
@@ -617,7 +618,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_UnrelatedKeys_DoesNotNotifyListeners() public async Task ApplyPatchAsync_UnrelatedKeys_DoesNotNotifyListeners()
{ {
var listener = Substitute.For<IRatesChangedListener>(); var listener = Substitute.For<IRatesChangedListener>();
SettingsService local = new(_store, _cipher, new[] { listener }); SettingsService local = new(_store.Store, _cipher, new[] { listener });
await local.ApplyPatchAsync(JsonBody(new { minLen = 100, rateSource = "mock" }), CancellationToken.None); await local.ApplyPatchAsync(JsonBody(new { minLen = 100, rateSource = "mock" }), CancellationToken.None);
@@ -628,7 +629,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_TargetCurrencyJsonNull_DoesNotNotifyListeners() public async Task ApplyPatchAsync_TargetCurrencyJsonNull_DoesNotNotifyListeners()
{ {
var listener = Substitute.For<IRatesChangedListener>(); var listener = Substitute.For<IRatesChangedListener>();
SettingsService local = new(_store, _cipher, new[] { listener }); SettingsService local = new(_store.Store, _cipher, new[] { listener });
await local.ApplyPatchAsync(JsonBody(new Dictionary<string, object?> { ["targetCurrency"] = null }), CancellationToken.None); await local.ApplyPatchAsync(JsonBody(new Dictionary<string, object?> { ["targetCurrency"] = null }), CancellationToken.None);
@@ -11,6 +11,7 @@ using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants.Abstractions; using Deal.SharedKernel.Tenants.Abstractions;
@@ -44,7 +45,7 @@ public sealed class StorageTickSchedulerTests
{ {
FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old"); FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old");
FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old"); FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)); var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
var tenantContext = new TenantContext(); var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(tenants, tenantContext, storeA, storeB, settings); await using ServiceProvider provider = BuildProvider(tenants, tenantContext, storeA, storeB, settings);
@@ -73,7 +74,7 @@ public sealed class StorageTickSchedulerTests
FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old"); FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old");
var storeB = new FakeKanjStore(); var storeB = new FakeKanjStore();
storeB.SeedCard(Card("l_b_fresh", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromHours(1)))); storeB.SeedCard(Card("l_b_fresh", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromHours(1))));
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var tenantContext = new TenantContext(); var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider( await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)), new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)),
@@ -104,7 +105,7 @@ public sealed class StorageTickSchedulerTests
var settingsByTenant = new Dictionary<Guid, ISettingsStore> var settingsByTenant = new Dictionary<Guid, ISettingsStore>
{ {
[TenantA] = new ThrowingSettingsStore(), [TenantA] = new ThrowingSettingsStore(),
[TenantB] = new FakeSettingsStore(), [TenantB] = new TestSettingsStore().Store,
}; };
var storesByTenant = new Dictionary<Guid, FakeKanjStore> var storesByTenant = new Dictionary<Guid, FakeKanjStore>
{ {
@@ -151,7 +152,7 @@ public sealed class StorageTickSchedulerTests
public async Task RunCycle_PurgesExpiredRejectedRowsAndPublishesRejectedPurgeToast() public async Task RunCycle_PurgesExpiredRejectedRowsAndPublishesRejectedPurgeToast()
{ {
var kanjStore = new FakeKanjStore(); var kanjStore = new FakeKanjStore();
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var tenantContext = new TenantContext(); var tenantContext = new TenantContext();
var pipelineStoreA = new TestPipelineStore(); var pipelineStoreA = new TestPipelineStore();
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
@@ -161,7 +162,7 @@ public sealed class StorageTickSchedulerTests
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository, new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
tenantContext, tenantContext,
new Dictionary<Guid, FakeKanjStore> { [TenantA] = kanjStore, [TenantB] = new FakeKanjStore() }, new Dictionary<Guid, FakeKanjStore> { [TenantA] = kanjStore, [TenantB] = new FakeKanjStore() },
new Dictionary<Guid, ISettingsStore> { [TenantA] = settings, [TenantB] = settings }, new Dictionary<Guid, ISettingsStore> { [TenantA] = settings.Store, [TenantB] = settings.Store },
new Dictionary<Guid, TestPipelineStore> { [TenantA] = pipelineStoreA, [TenantB] = new TestPipelineStore() }); new Dictionary<Guid, TestPipelineStore> { [TenantA] = pipelineStoreA, [TenantB] = new TestPipelineStore() });
SseBroker broker = provider.GetRequiredService<SseBroker>(); SseBroker broker = provider.GetRequiredService<SseBroker>();
@@ -191,7 +192,7 @@ public sealed class StorageTickSchedulerTests
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository, new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
tenantContext, tenantContext,
new Dictionary<Guid, FakeKanjStore> { [TenantA] = cardStoreA, [TenantB] = cardStoreB }, new Dictionary<Guid, FakeKanjStore> { [TenantA] = cardStoreA, [TenantB] = cardStoreB },
new Dictionary<Guid, ISettingsStore> { [TenantA] = new FakeSettingsStore(), [TenantB] = new FakeSettingsStore() }, new Dictionary<Guid, ISettingsStore> { [TenantA] = new TestSettingsStore().Store, [TenantB] = new TestSettingsStore().Store },
new Dictionary<Guid, TestPipelineStore> { [TenantA] = new(), [TenantB] = new() }); new Dictionary<Guid, TestPipelineStore> { [TenantA] = new(), [TenantB] = new() });
SseBroker broker = provider.GetRequiredService<SseBroker>(); SseBroker broker = provider.GetRequiredService<SseBroker>();
@@ -219,14 +220,14 @@ public sealed class StorageTickSchedulerTests
{ {
var cardStoreA = new FakeKanjStore(); var cardStoreA = new FakeKanjStore();
cardStoreA.SeedCard(HoldCard("c_past", title: "Старое", reminderAtMs: NowMs() - 60_000)); cardStoreA.SeedCard(HoldCard("c_past", title: "Старое", reminderAtMs: NowMs() - 60_000));
var settingsA = new FakeSettingsStore(); var settingsA = new TestSettingsStore();
settingsA.Preload(SettingsKeys.RemindersEnabled, "false"); settingsA.Preload(SettingsKeys.RemindersEnabled, "false");
var tenantContext = new TenantContext(); var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider( await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA)).Repository, new TestTenantRepository(Tenant(TenantA)).Repository,
tenantContext, tenantContext,
new Dictionary<Guid, FakeKanjStore> { [TenantA] = cardStoreA }, new Dictionary<Guid, FakeKanjStore> { [TenantA] = cardStoreA },
new Dictionary<Guid, ISettingsStore> { [TenantA] = settingsA }, new Dictionary<Guid, ISettingsStore> { [TenantA] = settingsA.Store },
new Dictionary<Guid, TestPipelineStore> { [TenantA] = new() }); new Dictionary<Guid, TestPipelineStore> { [TenantA] = new() });
SseBroker broker = provider.GetRequiredService<SseBroker>(); SseBroker broker = provider.GetRequiredService<SseBroker>();
@@ -252,7 +253,7 @@ public sealed class StorageTickSchedulerTests
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository, new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
tenantContext, tenantContext,
new Dictionary<Guid, FakeKanjStore> { [TenantA] = cardStoreA, [TenantB] = storeB }, new Dictionary<Guid, FakeKanjStore> { [TenantA] = cardStoreA, [TenantB] = storeB },
new Dictionary<Guid, ISettingsStore> { [TenantA] = new FakeSettingsStore(), [TenantB] = new FakeSettingsStore() }, new Dictionary<Guid, ISettingsStore> { [TenantA] = new TestSettingsStore().Store, [TenantB] = new TestSettingsStore().Store },
new Dictionary<Guid, TestPipelineStore> { [TenantA] = new(), [TenantB] = new() }); new Dictionary<Guid, TestPipelineStore> { [TenantA] = new(), [TenantB] = new() });
SseBroker broker = provider.GetRequiredService<SseBroker>(); SseBroker broker = provider.GetRequiredService<SseBroker>();
@@ -283,13 +284,13 @@ public sealed class StorageTickSchedulerTests
TenantContext tenantContext, TenantContext tenantContext,
FakeKanjStore storeA, FakeKanjStore storeA,
FakeKanjStore storeB, FakeKanjStore storeB,
FakeSettingsStore settings) TestSettingsStore settings)
{ {
return BuildProvider( return BuildProvider(
tenants.Repository, tenants.Repository,
tenantContext, tenantContext,
new Dictionary<Guid, FakeKanjStore> { [TenantA] = storeA, [TenantB] = storeB }, new Dictionary<Guid, FakeKanjStore> { [TenantA] = storeA, [TenantB] = storeB },
new Dictionary<Guid, ISettingsStore> { [TenantA] = settings, [TenantB] = settings }); new Dictionary<Guid, ISettingsStore> { [TenantA] = settings.Store, [TenantB] = settings.Store });
} }
// Строит DI-провайдер теста по явным картам тенант → хранилище/настройки. // Строит DI-провайдер теста по явным картам тенант → хранилище/настройки.
@@ -3,6 +3,7 @@ using Deal.Api.Events;
using Deal.Grpc.Telegram; using Deal.Grpc.Telegram;
using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Telegram.Application; using Deal.Modules.Telegram.Application;
using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
@@ -61,7 +62,7 @@ public sealed class TelegramIngressServiceTests
public async Task SyncDialogs_AutoMonitorNewDisabled_ReturnsEmptyMonitoredMirror() public async Task SyncDialogs_AutoMonitorNewDisabled_ReturnsEmptyMonitoredMirror()
{ {
var registry = TestRegistry.With(Tenant(TenantA)); var registry = TestRegistry.With(Tenant(TenantA));
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
settings.Preload(SettingsKeys.AutoMonitorNew, "false"); settings.Preload(SettingsKeys.AutoMonitorNew, "false");
var dialogs = new TestTelegramStore(); var dialogs = new TestTelegramStore();
@@ -69,7 +70,7 @@ public sealed class TelegramIngressServiceTests
registry, registry,
services => services =>
{ {
services.AddScoped<ISettingsStore>(_ => settings); services.AddScoped<ISettingsStore>(_ => settings.Store);
services.AddScoped<ITelegramStore>(_ => dialogs.Store); services.AddScoped<ITelegramStore>(_ => dialogs.Store);
}, },
async channel => async channel =>
@@ -91,7 +92,7 @@ public sealed class TelegramIngressServiceTests
[Fact] [Fact]
public async Task ReportStatus_PersistsStatusAndPublishesTransitionToasts() public async Task ReportStatus_PersistsStatusAndPublishesTransitionToasts()
{ {
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var registry = TestRegistry.With(Tenant(TenantA)); var registry = TestRegistry.With(Tenant(TenantA));
var broker = new SseBroker(); var broker = new SseBroker();
@@ -100,7 +101,7 @@ public sealed class TelegramIngressServiceTests
services => services =>
{ {
services.AddSingleton<SseBroker>(broker); services.AddSingleton<SseBroker>(broker);
services.AddScoped<ISettingsStore>(_ => settings); services.AddScoped<ISettingsStore>(_ => settings.Store);
}, },
async channel => async channel =>
{ {
@@ -138,12 +139,12 @@ public sealed class TelegramIngressServiceTests
[Fact] [Fact]
public async Task ReportStatus_UnknownTenant_NotSavedWithoutRpcError() public async Task ReportStatus_UnknownTenant_NotSavedWithoutRpcError()
{ {
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var registry = TestRegistry.With(Tenant(TenantA)); var registry = TestRegistry.With(Tenant(TenantA));
await RunAsync( await RunAsync(
registry, registry,
services => services.AddScoped<ISettingsStore>(_ => settings), services => services.AddScoped<ISettingsStore>(_ => settings.Store),
async channel => async channel =>
{ {
var client = new IngressService.IngressServiceClient(channel); var client = new IngressService.IngressServiceClient(channel);
@@ -261,7 +262,7 @@ public sealed class TelegramIngressServiceTests
// phase: Ожидаемая фаза. // phase: Ожидаемая фаза.
// connected: Ожидаемый флаг connected. // connected: Ожидаемый флаг connected.
private static void AssertStatusStored( private static void AssertStatusStored(
FakeSettingsStore settings, TestSettingsStore settings,
string phase, string phase,
bool connected) bool connected)
{ {
@@ -99,7 +99,7 @@ internal static class TelegramIngressTestHost
.AddCheck("ready", () => HealthCheckResult.Healthy("хост готов")); .AddCheck("ready", () => HealthCheckResult.Healthy("хост готов"));
} }
builder.Services.AddScoped<ISettingsStore>(_ => new FakeSettingsStore()); builder.Services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store);
builder.Services.AddScoped<ITelegramStore>(_ => new TestTelegramStore().Store); builder.Services.AddScoped<ITelegramStore>(_ => new TestTelegramStore().Store);
builder.Services.AddScoped<ITelegramGateway>(_ => new TestTelegramGateway().Gateway); builder.Services.AddScoped<ITelegramGateway>(_ => new TestTelegramGateway().Gateway);
builder.Services.AddTelegramModule(); builder.Services.AddTelegramModule();
@@ -71,7 +71,7 @@ public sealed class TelegramSourceIngestObserverTests
var store = new TestTelegramStore(); var store = new TestTelegramStore();
var dialogs = new DialogsService( var dialogs = new DialogsService(
store.Store, store.Store,
new FakeSettingsStore(), new TestSettingsStore().Store,
new TestTelegramGateway().Gateway, new TestTelegramGateway().Gateway,
NullLogger<DialogsService>.Instance); NullLogger<DialogsService>.Instance);
var observer = new TelegramSourceIngestObserver(dialogs, NullLogger<TelegramSourceIngestObserver>.Instance); var observer = new TelegramSourceIngestObserver(dialogs, NullLogger<TelegramSourceIngestObserver>.Instance);
@@ -0,0 +1,62 @@
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using NSubstitute;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Подставка <see cref="ISettingsStore"/> на словаре: сервисы получают NSubstitute-подставку
/// (<see cref="Store"/>), тесты сеют/проверяют настройки через <see cref="Preload"/>,
/// <see cref="GetStoredJson"/> и <see cref="Keys"/>.
/// </summary>
public sealed class TestSettingsStore
{
private readonly Dictionary<string, SettingValue> _rows = new(StringComparer.Ordinal);
/// <summary>
/// Подставка порта настроек (создаётся в конструкторе).
/// </summary>
public ISettingsStore Store { get; }
/// <summary>
/// Ключи сохранённых настроек.
/// </summary>
public IReadOnlyCollection<string> Keys => _rows.Keys.ToList();
/// <summary>
/// Создаёт подставку с пустым словарём.
/// </summary>
public TestSettingsStore()
{
Store = Substitute.For<ISettingsStore>();
Store.GetAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => _rows.TryGetValue(ci.ArgAt<string>(0), out SettingValue? row) ? row : null);
Store.GetAllAsync(Arg.Any<CancellationToken>())
.Returns(ci => _rows.Values.ToList());
Store.When(s => s.SetAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci => _rows[ci.ArgAt<string>(0)] =
new SettingValue(ci.ArgAt<string>(0), ci.ArgAt<string>(1), DateTimeOffset.UtcNow));
Store.When(s => s.RemoveAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci => _rows.Remove(ci.ArgAt<string>(0)));
}
/// <summary>
/// Кладёт готовую строку
/// </summary>
/// <param name="key">Ключ настройки.</param>
/// <param name="valueJson">Значение, сериализованное в JSON.</param>
public void Preload(string key, string valueJson)
{
_rows[key] = new SettingValue(key, valueJson, DateTimeOffset.UtcNow);
}
/// <summary>
/// Возвращает JSON сохранённого значения или null, если ключа нет.
/// </summary>
/// <param name="key">Ключ настройки.</param>
/// <returns>JSON-строка значения или null.</returns>
public string? GetStoredJson(string key)
{
return _rows.TryGetValue(key, out SettingValue? row) ? row.ValueJson : null;
}
}
@@ -26,7 +26,7 @@ public sealed class TokenUsageRecorderTests
[Fact] [Fact]
public async Task AddAsync_ChargesTenantLimitsAccumulatesLifetimeKvAndWritesEvent() public async Task AddAsync_ChargesTenantLimitsAccumulatesLifetimeKvAndWritesEvent()
{ {
(FakeSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); (TestSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
await recorder.AddAsync(new Usage { Prompt = 500, Completion = 40, Total = 540 }, "deepseek", "deepseek-chat", CancellationToken.None); await recorder.AddAsync(new Usage { Prompt = 500, Completion = 40, Total = 540 }, "deepseek", "deepseek-chat", CancellationToken.None);
@@ -52,7 +52,7 @@ public sealed class TokenUsageRecorderTests
[Fact] [Fact]
public async Task AddAsync_AccumulatesAcrossCalls() public async Task AddAsync_AccumulatesAcrossCalls()
{ {
(FakeSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); (TestSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
await recorder.AddAsync(new Usage { Prompt = 500, Completion = 40, Total = 540 }, "deepseek", "m", CancellationToken.None); await recorder.AddAsync(new Usage { Prompt = 500, Completion = 40, Total = 540 }, "deepseek", "m", CancellationToken.None);
await recorder.AddAsync(new Usage { Prompt = 300, Completion = 60, Total = 360 }, "deepseek", "m", CancellationToken.None); await recorder.AddAsync(new Usage { Prompt = 300, Completion = 60, Total = 360 }, "deepseek", "m", CancellationToken.None);
@@ -68,7 +68,7 @@ public sealed class TokenUsageRecorderTests
[Fact] [Fact]
public async Task AddAsync_NullUsage_IsNoop() public async Task AddAsync_NullUsage_IsNoop()
{ {
(FakeSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); (TestSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
await recorder.AddAsync(null, "deepseek", "m", CancellationToken.None); await recorder.AddAsync(null, "deepseek", "m", CancellationToken.None);
@@ -80,7 +80,7 @@ public sealed class TokenUsageRecorderTests
[Fact] [Fact]
public async Task AddAsync_ZeroTotal_AccumulatesKvAndWritesEventButSkipsTenantLimits() public async Task AddAsync_ZeroTotal_AccumulatesKvAndWritesEventButSkipsTenantLimits()
{ {
(FakeSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); (TestSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
// Ответ модели без оценки (total=0): lifetime-KV и событие пишутся (вызов состоялся), строка лимита // Ответ модели без оценки (total=0): lifetime-KV и событие пишутся (вызов состоялся), строка лимита
// нулевым расходом не заводится — ленивый GetOrCreate остаётся первому реальному списанию/чтению. // нулевым расходом не заводится — ленивый GetOrCreate остаётся первому реальному списанию/чтению.
@@ -96,11 +96,11 @@ public sealed class TokenUsageRecorderTests
[Fact] [Fact]
public async Task AddAsync_OutsideTenantContext_Throws() public async Task AddAsync_OutsideTenantContext_Throws()
{ {
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var limits = new TestTenantLimitStore(); var limits = new TestTenantLimitStore();
ITenantContext tenantContext = new TenantContext(); // без SetTenant — списание вне tenant-контекста невозможно. ITenantContext tenantContext = new TenantContext(); // без SetTenant — списание вне tenant-контекста невозможно.
var recorder = new TokenUsageRecorder( var recorder = new TokenUsageRecorder(
settings, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)); settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store));
await Assert.ThrowsAsync<InvalidOperationException>( await Assert.ThrowsAsync<InvalidOperationException>(
() => recorder.AddAsync(new Usage { Prompt = 1, Completion = 1, Total = 2 }, "deepseek", "m", CancellationToken.None)); () => recorder.AddAsync(new Usage { Prompt = 1, Completion = 1, Total = 2 }, "deepseek", "m", CancellationToken.None));
@@ -111,7 +111,7 @@ public sealed class TokenUsageRecorderTests
[Fact] [Fact]
public async Task AddEstimatedAsync_MlCall_WritesMlEventOnlyWithCharEstimate() public async Task AddEstimatedAsync_MlCall_WritesMlEventOnlyWithCharEstimate()
{ {
(FakeSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder(); (TestSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
// 20 символов → 5 токенов (≈chars/4); бюджет и lifetime AI-счётчик не трогаются. // 20 символов → 5 токенов (≈chars/4); бюджет и lifetime AI-счётчик не трогаются.
long estimated = await recorder.AddEstimatedAsync( long estimated = await recorder.AddEstimatedAsync(
@@ -141,14 +141,14 @@ public sealed class TokenUsageRecorderTests
// Создаёт recorder сценария: tenant-контекст + фейки KV/лимитов/истории. // Создаёт recorder сценария: tenant-контекст + фейки KV/лимитов/истории.
// Возвращает: Кортеж (настройки, лимиты, история, recorder). // Возвращает: Кортеж (настройки, лимиты, история, recorder).
private static (FakeSettingsStore Settings, TestTenantLimitStore Limits, TestTokenUsageEventStore Events, TokenUsageRecorder Recorder) CreateRecorder() private static (TestSettingsStore Settings, TestTenantLimitStore Limits, TestTokenUsageEventStore Events, TokenUsageRecorder Recorder) CreateRecorder()
{ {
var settings = new FakeSettingsStore(); var settings = new TestSettingsStore();
var limits = new TestTenantLimitStore(); var limits = new TestTenantLimitStore();
var events = new TestTokenUsageEventStore(); var events = new TestTokenUsageEventStore();
ITenantContext tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue)); tenantContext.SetTenant(new TenantId(TenantIdValue));
var recorder = new TokenUsageRecorder(settings, limits.Store, tenantContext, new TokenUsageEventService(events.Store)); var recorder = new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(events.Store));
return (settings, limits, events, recorder); return (settings, limits, events, recorder);
} }
} }