Перевести тестовые фейки на моки NSubstitute #13

Merged
rust merged 12 commits from t1_NSubstitute_tests into main 2026-09-13 03:28:11 +03:00
44 changed files with 327 additions and 315 deletions
Showing only changes of commit d51d88ed65 - Show all commits
@@ -94,8 +94,8 @@ public sealed class DiscoveryWorkerSchedulerTests
var tenantContext = new TenantContext();
var storeA = new TestDiscoveryStore();
var storeB = new TestDiscoveryStore();
var settingsA = new FakeSettingsStore();
var settingsB = new FakeSettingsStore();
var settingsA = new TestSettingsStore();
var settingsB = new TestSettingsStore();
var gatewayA = new TestDiscoveryGateway();
var gatewayB = new TestDiscoveryGateway();
PreloadDiscoveryEvalOff(settingsA);
@@ -109,7 +109,7 @@ public sealed class DiscoveryWorkerSchedulerTests
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
// (эталон PipelineWorkerSchedulerTests/ConnectionStringProvider.ForTenant).
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);
// Реальные сервисы модуля Discovery — как AddDiscoveryModule в Program.cs: цикл резолвит их в tenant-scope.
services.AddDiscoveryModule();
@@ -124,7 +124,7 @@ public sealed class DiscoveryWorkerSchedulerTests
}
// ML/ИИ выключены в настройках тенанта (оценка содержания — эвристика по ключам).
private static void PreloadDiscoveryEvalOff(FakeSettingsStore settings)
private static void PreloadDiscoveryEvalOff(TestSettingsStore settings)
{
settings.Preload(SettingsKeys.MlEnabled, "false");
settings.Preload(SettingsKeys.AiEnabled, "false");
@@ -163,7 +163,7 @@ public sealed class MlOutboxFlushSchedulerTests
services.AddScoped<IMlTrainClient>(provider => provider.GetRequiredService<GrpcMlClient>());
// Tenant-scoped адаптеры: выбирают фейк по тому же ITenantContext, который планировщик заполняет SetTenant.
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<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
services.AddScoped<TokenUsageRecorder>();
@@ -43,7 +43,7 @@ public sealed class TgStatusServiceTests
[Fact]
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_2", "Группа", "group", Monitor: true));
store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false));
@@ -69,7 +69,7 @@ public sealed class TgStatusServiceTests
[Fact]
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));
settings.Preload(SettingsKeys.TgAccount, "\"@realuser\"");
PreloadKeys(globalSettings, "123456", "abcdefghijklmnop");
@@ -106,16 +106,16 @@ public sealed class TgStatusServiceTests
// ─── Хелперы ────────────────────────────────────────────────────────────
// Собирает сервис на фейках (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 settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
var gateway = new TestTelegramGateway();
var cipher = TestCiphers.New();
var globalSettings = new TestGlobalSettingsStore();
var keys = new TelegramKeysService(globalSettings.Store, cipher);
var dialogs = new DialogsService(store.Store, settings, gateway.Gateway, NullLogger<DialogsService>.Instance);
var service = new TgStatusService(gateway.Gateway, dialogs, settings, keys);
var dialogs = new DialogsService(store.Store, settings.Store, gateway.Gateway, NullLogger<DialogsService>.Instance);
var service = new TgStatusService(gateway.Gateway, dialogs, settings.Store, keys);
return (service, store, settings, gateway, cipher, globalSettings);
}
@@ -128,7 +128,7 @@ public sealed class BudgetedAiClassifierTests
Context ctx = Create(
limits => limits.Preload(
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к$";
AiParsedCardDto result = await ctx.Decorator.ClassifyAsync(text, CancellationToken.None);
@@ -152,7 +152,7 @@ public sealed class BudgetedAiClassifierTests
private sealed record Context(
IAiClassifier Decorator,
TestAiClassifier Paid,
FakeSettingsStore Settings);
TestSettingsStore Settings);
// Собирает контекст: платный фейк + реальный Local-классификатор + фейк лимитов и tenant-контекст
// (зеркало регистрации AddDealIntegrations при UseLocal=false).
@@ -160,7 +160,7 @@ public sealed class BudgetedAiClassifierTests
// Возвращает: Контекст теста.
private static Context Create(Action<TestTenantLimitStore>? configure = null)
{
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
var limits = new TestTenantLimitStore();
configure?.Invoke(limits);
ITenantContext tenantContext = new TenantContext();
@@ -168,7 +168,7 @@ public sealed class BudgetedAiClassifierTests
var paid = new TestAiClassifier();
IAiClassifier decorator = new BudgetedAiClassifier(
paid.Classifier,
new LocalAiClassifier(new LocalFieldsParser(settings)),
new LocalAiClassifier(new LocalFieldsParser(settings.Store)),
limits.Store,
tenantContext,
NullLogger<BudgetedAiClassifier>.Instance);
@@ -193,7 +193,7 @@ public sealed class CardComposerTests
[Fact]
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
CardSnapshot snapshot = await composer.BuildAsync(
@@ -232,11 +232,11 @@ public sealed class CardComposerTests
// Создаёт контекст теста: пустые хранилища (дефолты: конверсия включена, курсы — мок).
// Возвращает: Кортеж (композитор, канбан-хранилище, KV-настройки).
private static (CardComposer Composer, FakeKanjStore Store, FakeSettingsStore Settings) Create()
private static (CardComposer Composer, FakeKanjStore Store, TestSettingsStore Settings) Create()
{
var store = new FakeKanjStore();
var settings = new FakeSettingsStore();
return (new CardComposer(store, settings), store, settings);
var settings = new TestSettingsStore();
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 settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
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) либо без них.
@@ -38,7 +38,7 @@ public sealed class DialogsServiceTests
[Fact]
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");
int count = await service.SyncFromTelegramAsync(Catalog(Entry("d_1", "Канал", "channel")), CancellationToken.None);
@@ -497,12 +497,12 @@ public sealed class DialogsServiceTests
// ─── Хелперы ────────────────────────────────────────────────────────────
// Собирает сервис на фейках (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 settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
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 + флаги/хвост каталога).
@@ -168,14 +168,14 @@ public sealed class DiscoveryEvaluatorTests
=> 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.MlEnabled, mlEnabled ? "true" : "false");
var ml = new TestMlClient();
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).
@@ -419,7 +419,7 @@ public sealed class DiscoveryWorkerServiceTests
// Контекст теста: воркер на фейках + ссылки для проверок состояния.
private sealed record Fixture(
TestDiscoveryStore Store,
FakeSettingsStore Settings,
TestSettingsStore Settings,
TestDiscoveryGateway Gateway,
IDiscoveryPacer Pacer,
DiscoveryWorkerService Worker,
@@ -429,7 +429,7 @@ public sealed class DiscoveryWorkerServiceTests
private static Fixture Create()
{
var store = new TestDiscoveryStore();
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
settings.Preload(SettingsKeys.MlEnabled, "false");
settings.Preload(SettingsKeys.AiEnabled, "false");
var ml = new TestMlClient();
@@ -437,13 +437,13 @@ public sealed class DiscoveryWorkerServiceTests
var log = new DiscoveryLogService(store.Store);
var blacklist = new DiscoveryBlacklistService(store.Store);
var candidates = new DiscoveryCandidatesService(store.Store, log, blacklist);
var planGuard = new DiscoveryPlanGuard(store.Store, settings);
var tasks = new DiscoveryTasksService(store.Store, planGuard, settings);
var banGuard = new DiscoveryBanGuard(store.Store, settings);
var planGuard = new DiscoveryPlanGuard(store.Store, settings.Store);
var tasks = new DiscoveryTasksService(store.Store, planGuard, settings.Store);
var banGuard = new DiscoveryBanGuard(store.Store, settings.Store);
var pacer = Substitute.For<IDiscoveryPacer>();
var gateway = new TestDiscoveryGateway();
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);
}
@@ -39,7 +39,7 @@ public sealed class GrpcAiToolsTests
reply.Keywords.Add("python разработка");
reply.Keywords.Add("telegram каналы");
service.GenerateKeywordsReply = reply;
FakeSettingsStore settings = new();
TestSettingsStore settings = new();
ISecretCipher cipher = TestCiphers.New();
TestTenantLimitStore limits = new();
@@ -66,7 +66,7 @@ public sealed class GrpcAiToolsTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.KeywordsUnavailable = true;
IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New());
IAiTools tools = CreateTools(port, new TestSettingsStore(), TestCiphers.New());
AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("описание", CancellationToken.None);
@@ -89,7 +89,7 @@ public sealed class GrpcAiToolsTests
Usage = new Usage { Prompt = 200, Completion = 10, Total = 210 },
};
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(
"Ищу дизайнера для лендинга", "Разработка сайтов на Python", new[] { "python", "бэкенд" }, CancellationToken.None);
@@ -113,7 +113,7 @@ public sealed class GrpcAiToolsTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.FitUnavailable = true;
IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New());
IAiTools tools = CreateTools(port, new TestSettingsStore(), TestCiphers.New());
await Assert.ThrowsAsync<AiUnavailableException>(
() => tools.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None));
@@ -128,7 +128,7 @@ public sealed class GrpcAiToolsTests
// Возвращает: Экземпляр GrpcAiTools.
private static GrpcAiTools CreateTools(
int port,
FakeSettingsStore settings,
TestSettingsStore settings,
ISecretCipher cipher,
TestTenantLimitStore? limits = null)
{
@@ -139,8 +139,8 @@ public sealed class GrpcAiToolsTests
return new GrpcAiTools(
tenantContext,
connection,
new AiProviderConfigBuilder(settings, cipher),
new TokenUsageRecorder(settings, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
new AiProviderConfigBuilder(settings.Store, cipher),
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiTools>.Instance);
}
}
@@ -110,7 +110,7 @@ public sealed class GrpcMlClientTests
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.AiDecisions, "3");
var learning = new FakeMlLearningStore { LearningCount = 9 };
@@ -309,21 +309,22 @@ public sealed class GrpcMlClientTests
// Возвращает: Экземпляр GrpcMlClient в tenant-контексте теста.
private static GrpcMlClient CreateClient(
int port,
FakeSettingsStore? settings = null,
TestSettingsStore? settings = null,
FakeMlLearningStore? learning = null,
MlStatusCache? cache = null)
{
settings ??= new TestSettingsStore();
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue));
var options = new MlServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" };
return new GrpcMlClient(
tenantContext,
settings ?? new FakeSettingsStore(),
settings.Store,
learning ?? new FakeMlLearningStore(),
new MlGrpcConnection(options),
cache ?? new MlStatusCache(),
new TokenUsageRecorder(
settings ?? new FakeSettingsStore(),
settings.Store,
new TestTenantLimitStore().Store,
tenantContext,
new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
@@ -144,7 +144,7 @@ public sealed class IntegrationsDiTests
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N")));
services.AddSingleton<ITenantContext>(tenantContext);
services.AddScoped<ISettingsStore>(_ => new FakeSettingsStore());
services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store);
services.AddScoped<ISecretCipher>(_ => TestCiphers.New());
services.AddScoped<ICardStore>(_ => new FakeKanjStore());
services.AddScoped<IMlLearningStore>(_ => new FakeMlLearningStore());
@@ -2,6 +2,7 @@ using Deal.Contracts.Integrations.Models;
using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Contracts;
@@ -13,10 +14,10 @@ public sealed class LocalAiClassifierTests
{
// Создаёт контекст теста: пустое KV-хранилище (дефолты маркеров) + адаптер.
// Возвращает: Кортеж (KV-настройки, классификатор).
private static (FakeSettingsStore Settings, LocalAiClassifier Classifier) CreateContext()
private static (TestSettingsStore Settings, LocalAiClassifier Classifier) CreateContext()
{
var settings = new FakeSettingsStore();
var classifier = new LocalAiClassifier(new LocalFieldsParser(settings));
var settings = new TestSettingsStore();
var classifier = new LocalAiClassifier(new LocalFieldsParser(settings.Store));
return (settings, classifier);
}
@@ -140,7 +141,7 @@ public sealed class LocalAiClassifierTests
[Fact]
public async Task Classify_CustomHireMarkersOverride_AreUsed()
{
(FakeSettingsStore settings, LocalAiClassifier classifier) = CreateContext();
(TestSettingsStore settings, LocalAiClassifier classifier) = CreateContext();
// Дефолтные маркеры заменены: «вакансия» больше не маркер найма, свой — маркер.
settings.Preload(SettingsKeys.HireMarkers, "[\"нанимаем джунов\"]");
@@ -88,7 +88,7 @@ public sealed class PipelineCardWriterTests
{
var store = new FakeKanjStore();
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);
}
@@ -156,7 +156,7 @@ public sealed class PipelineWorkerGrpcAiTests
PipelineWorkerService Worker,
TestPipelineStore PipelineStore,
FakeKanjStore KanjStore,
FakeSettingsStore Settings,
TestSettingsStore Settings,
TestTenantLimitStore Limits);
private static Context CreateContext(
@@ -164,12 +164,12 @@ public sealed class PipelineWorkerGrpcAiTests
TestTenantLimitStore? limits = null,
bool budgeted = false)
{
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
var pipelineStore = new TestPipelineStore();
var kanjStore = new FakeKanjStore();
var mlClient = new TestMlClient { Predict = NotReadyPrediction() };
var rules = new IncomingRules(settings);
var fieldsParser = new LocalFieldsParser(settings);
var rules = new IncomingRules(settings.Store);
var fieldsParser = new LocalFieldsParser(settings.Store);
ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue));
@@ -179,9 +179,9 @@ public sealed class PipelineWorkerGrpcAiTests
var grpcClassifier = new GrpcAiClassifier(
tenantContext,
connection,
new AiProviderConfigBuilder(settings, TestCiphers.New()),
new AiClassifyContextBuilder(settings, kanjStore),
new TokenUsageRecorder(settings, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
new AiProviderConfigBuilder(settings.Store, TestCiphers.New()),
new AiClassifyContextBuilder(settings.Store, kanjStore),
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiClassifier>.Instance);
IAiClassifier aiClassifier = budgeted
? new BudgetedAiClassifier(
@@ -193,10 +193,10 @@ public sealed class PipelineWorkerGrpcAiTests
: grpcClassifier;
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 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);
}
@@ -82,7 +82,7 @@ public sealed class CardMoverTests
private static (CardMover Mover, FakeKanjStore Store) Create()
{
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);
}
}
@@ -40,8 +40,8 @@ public sealed class DiscoveryBanGuardTests
// Вчерашние вступления в суточную квоту не входят: 49 сегодня → ещё можно.
TestDiscoveryStore yesterdayStore = new(() => Now);
var settings = new FakeSettingsStore();
var secondGuard = new DiscoveryBanGuard(yesterdayStore.Store, settings, () => Now);
var settings = new TestSettingsStore();
var secondGuard = new DiscoveryBanGuard(yesterdayStore.Store, settings.Store, () => Now);
for (int i = 0; i < 49; i++)
{
yesterdayStore.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddMinutes(i));
@@ -67,7 +67,7 @@ public sealed class DiscoveryBanGuardTests
[Fact]
public async Task NoteFlood_BlocksAutoJoinsUntilEndOfDay()
{
(DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create();
(DiscoveryBanGuard guard, _, TestSettingsStore settings) = Create();
await guard.NoteFloodAsync(CancellationToken.None);
@@ -79,7 +79,7 @@ public sealed class DiscoveryBanGuardTests
[Fact]
public async Task FloodYesterday_DoesNotBlockToday()
{
(DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create();
(DiscoveryBanGuard guard, _, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.DiscFloodDay, TodayStart.AddDays(-1).ToUnixTimeMilliseconds().ToString());
Assert.False(await guard.FloodTodayAsync(CancellationToken.None));
@@ -89,7 +89,7 @@ public sealed class DiscoveryBanGuardTests
[Fact]
public async Task GlobalPaused_BlocksAutoJoins()
{
(DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create();
(DiscoveryBanGuard guard, _, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.DiscPaused, "true");
Assert.True(await guard.GlobalPausedAsync(CancellationToken.None));
@@ -99,7 +99,7 @@ public sealed class DiscoveryBanGuardTests
[Fact]
public async Task CustomJoinLimit_AppliedWhenSettingStored()
{
(DiscoveryBanGuard guard, TestDiscoveryStore store, FakeSettingsStore settings) = Create();
(DiscoveryBanGuard guard, TestDiscoveryStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.DiscJoinLimit, "2");
Assert.True(await guard.CanAutoJoinAsync(CancellationToken.None));
@@ -109,11 +109,11 @@ public sealed class DiscoveryBanGuardTests
}
// Создаёт связку гард + фейки на фиксированных «часах» (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 settings = new FakeSettingsStore();
var guard = new DiscoveryBanGuard(store.Store, settings, () => Now);
var settings = new TestSettingsStore();
var guard = new DiscoveryBanGuard(store.Store, settings.Store, () => Now);
return (guard, store, settings);
}
}
@@ -117,7 +117,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact]
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("discEvalSample", "20");
@@ -330,12 +330,12 @@ public sealed class DiscoveryTasksServiceTests
}
// Создаёт связку сервис + фейки (пустые настройки → дефолты SettingsDefaults).
private static (DiscoveryTasksService Service, TestDiscoveryStore Store, FakeSettingsStore Settings) Create()
private static (DiscoveryTasksService Service, TestDiscoveryStore Store, TestSettingsStore Settings) Create()
{
var store = new TestDiscoveryStore();
var settings = new FakeSettingsStore();
var planGuard = new DiscoveryPlanGuard(store.Store, settings);
return (new DiscoveryTasksService(store.Store, planGuard, settings), store, settings);
var settings = new TestSettingsStore();
var planGuard = new DiscoveryPlanGuard(store.Store, settings.Store);
return (new DiscoveryTasksService(store.Store, planGuard, settings.Store), store, settings);
}
// Draft задачи с дефолтными полями (name/planJoins переопределяются).
@@ -2,6 +2,7 @@ using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
@@ -24,11 +25,11 @@ public sealed class AiClassifyContextBuilderTests
[Fact]
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.DomainDescription, Json(Domain));
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);
@@ -38,9 +39,9 @@ public sealed class AiClassifyContextBuilderTests
[Fact]
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}"));
var builder = new AiClassifyContextBuilder(settings, kanj);
var builder = new AiClassifyContextBuilder(settings.Store, kanj);
string prompt = await builder.BuildFilterPromptAsync(CancellationToken.None);
@@ -52,12 +53,12 @@ public sealed class AiClassifyContextBuilderTests
[Fact]
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.CardPrompt, Json("Верни блок «О заявке» {keywords}."));
settings.Preload(SettingsKeys.DomainDescription, Json(Domain));
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);
@@ -69,8 +70,8 @@ public sealed class AiClassifyContextBuilderTests
[Fact]
public async Task BuildClassifyUserContext_EmptyBoards_ShowsNoBoardsPhrase()
{
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
var builder = new AiClassifyContextBuilder(settings, kanj);
TestSettingsStore settings = Context(out FakeKanjStore kanj);
var builder = new AiClassifyContextBuilder(settings.Store, kanj);
string context = await builder.BuildClassifyUserContextAsync("Нужен разработчик", CancellationToken.None);
@@ -82,7 +83,7 @@ public sealed class AiClassifyContextBuilderTests
[Fact]
public async Task BuildClassifyUserContext_ListsBoardsWithKeywordsAndRules()
{
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
TestSettingsStore settings = Context(out FakeKanjStore kanj);
kanj.SeedBoard(new ContainerDto
{
Id = "b_py",
@@ -113,7 +114,7 @@ public sealed class AiClassifyContextBuilderTests
Order = 1,
});
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);
@@ -128,10 +129,10 @@ public sealed class AiClassifyContextBuilderTests
[Fact]
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_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);
@@ -144,8 +145,8 @@ public sealed class AiClassifyContextBuilderTests
[Fact]
public async Task BuildClassifyUserContext_LongMessage_TruncatesTo5000CodePoints()
{
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
var builder = new AiClassifyContextBuilder(settings, kanj);
TestSettingsStore settings = Context(out FakeKanjStore kanj);
var builder = new AiClassifyContextBuilder(settings.Store, kanj);
string text = new string('д', 6000);
string context = await builder.BuildClassifyUserContextAsync(text, CancellationToken.None);
@@ -158,10 +159,10 @@ public sealed class AiClassifyContextBuilderTests
// Создаёт контекст: пустое KV + пустой канбан-фейк.
// kanj: Канбан-фейк (доски/журнал примеров).
// Возвращает: KV-хранилище тенанта.
private static FakeSettingsStore Context(out FakeKanjStore kanj)
private static TestSettingsStore Context(out FakeKanjStore kanj)
{
kanj = new FakeKanjStore();
return new FakeSettingsStore();
return new TestSettingsStore();
}
// Кладёт карточку с исходным текстом и запись журнала 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 settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
if (!remindersEnabled)
{
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) — для посева напоминаний в прошлом/будущем.
@@ -24,8 +24,8 @@ public sealed class MlReviewServiceTests
TestMlClient ml,
out CardsService cards)
{
var settings = new FakeSettingsStore();
cards = new CardsService(kanj, settings, ml.Client, new TestFileStorage().Storage);
var settings = new TestSettingsStore();
cards = new CardsService(kanj, settings.Store, ml.Client, new TestFileStorage().Storage);
var processing = new PipelineProcessingService(pipeline.Store, ml.Client, new PipelineIngestService(pipeline.Store));
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.Services;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
@@ -47,7 +48,7 @@ public sealed class StorageTickServiceTests
[Fact]
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");
store.SeedBoard(Board("b_1"));
store.SeedCard(Card("l_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(30))));
@@ -62,7 +63,7 @@ public sealed class StorageTickServiceTests
[Fact]
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");
store.SeedBoard(Board("b_1"));
store.SeedCard(Card("l_older_month", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(32))));
@@ -78,7 +79,7 @@ public sealed class StorageTickServiceTests
[Fact]
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");
store.SeedCard(Card("l_inbox_old", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromDays(2))));
@@ -129,7 +130,7 @@ public sealed class StorageTickServiceTests
[Fact]
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");
DateTimeOffset now = DateTimeOffset.UtcNow;
store.SeedCard(Card("l_expired", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(10))));
@@ -165,7 +166,7 @@ public sealed class StorageTickServiceTests
[Fact]
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");
store.SeedCard(Card("l_old", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(5)))); // старше 3 дн.
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 settings = new FakeSettingsStore();
return (new StorageTickService(store, settings), store, settings);
var settings = new TestSettingsStore();
return (new StorageTickService(store, settings.Store), store, settings);
}
// Доска-колонка минимально (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,
TestPipelineStore PipelineStore,
FakeKanjStore KanjStore,
FakeSettingsStore Settings,
TestSettingsStore Settings,
TestAiClassifier AiClassifier,
PipelinePumpGate PumpGate,
SseSubscription Subscription);
@@ -202,7 +202,7 @@ public sealed class AdminTickOrchestratorTests
// Возвращает: Оркестратор, фейки и подписку канала тенанта.
private static Context CreateContext(bool withThrowingQueueRead = false, bool withThrowingReminderCheck = false)
{
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
// При сбое чтения очереди подменяем хранилище целиком (тот же объект идёт во все сервисы и в контекст).
TestPipelineStore store = new();
if (withThrowingQueueRead)
@@ -214,21 +214,21 @@ public sealed class AdminTickOrchestratorTests
var kanjStore = new FakeKanjStore();
var mlClient = new TestMlClient();
var aiClassifier = new TestAiClassifier();
var rules = new IncomingRules(settings);
var fieldsParser = new LocalFieldsParser(settings);
var rules = new IncomingRules(settings.Store);
var fieldsParser = new LocalFieldsParser(settings.Store);
var ingest = new PipelineIngestService(store.Store);
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 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 tickService = new StorageTickService(kanjStore, settings);
var tickService = new StorageTickService(kanjStore, settings.Store);
var toastPublisher = new StorageToastPublisher(broker);
var pumpGate = new PipelinePumpGate();
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(
tickService, processing, worker, cardsService, toastPublisher, broker, pumpGate,
NullLogger<AdminTickOrchestrator>.Instance);
@@ -23,7 +23,7 @@ public sealed class CardReclassifierTests
private sealed record Context(
CardReclassifier Reclassifier,
FakeKanjStore Store,
FakeSettingsStore Settings,
TestSettingsStore Settings,
TestMlClient MlClient,
TestAiClassifier AiClassifier,
ReclassifyGate Gate);
@@ -32,16 +32,16 @@ public sealed class CardReclassifierTests
// Возвращает: Сервис и фейки (сценарий до-настраивает карточки/доски/настройки).
private static Context CreateContext()
{
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
var store = new FakeKanjStore();
var mlClient = new TestMlClient();
var aiClassifier = new TestAiClassifier();
var fieldsParser = new LocalFieldsParser(settings);
var composer = new CardComposer(store, settings);
var cardsService = new CardsService(store, settings, mlClient.Client, new TestFileStorage().Storage);
var fieldsParser = new LocalFieldsParser(settings.Store);
var composer = new CardComposer(store, settings.Store);
var cardsService = new CardsService(store, settings.Store, mlClient.Client, new TestFileStorage().Storage);
var gate = new ReclassifyGate();
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);
}
@@ -242,7 +242,7 @@ public sealed class CardsServiceFilesTests
{
var store = new FakeKanjStore();
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 пуст).
@@ -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 settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
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).
@@ -340,7 +340,7 @@ public sealed class ContainersServiceTests
[Fact]
public async Task GetColState_ReturnsStoredColumnsAsIs()
{
(ContainersService service, _, FakeSettingsStore settings) = Create();
(ContainersService service, _, TestSettingsStore settings) = Create();
settings.Preload("colState", """{"inbox":{"collapsed":true},"trash":{"width":"lg"}}""");
IReadOnlyDictionary<string, ColumnStateDto> state = await service.GetColStateAsync(CancellationToken.None);
@@ -353,7 +353,7 @@ public sealed class ContainersServiceTests
[Fact]
public async Task PatchColState_NewColumn_WritesStateAndReturnsIt()
{
(ContainersService service, _, FakeSettingsStore settings) = Create();
(ContainersService service, _, TestSettingsStore settings) = Create();
ColumnStateDto result = await service.PatchColStateAsync(
"inbox", new ColumnStateDto(Collapsed: true, Width: null), CancellationToken.None);
@@ -368,7 +368,7 @@ public sealed class ContainersServiceTests
[Fact]
public async Task PatchColState_ExistingColumn_MergesPatchIntoCurrent()
{
(ContainersService service, _, FakeSettingsStore settings) = Create();
(ContainersService service, _, TestSettingsStore settings) = Create();
settings.Preload("colState", """{"inbox":{"collapsed":true}}""");
ColumnStateDto result = await service.PatchColStateAsync(
@@ -380,7 +380,7 @@ public sealed class ContainersServiceTests
[Fact]
public async Task PatchColState_NullFields_KeepCurrentState()
{
(ContainersService service, _, FakeSettingsStore settings) = Create();
(ContainersService service, _, TestSettingsStore settings) = Create();
settings.Preload("colState", """{"archive":{"collapsed":true,"width":"sm"}}""");
ColumnStateDto result = await service.PatchColStateAsync(
@@ -392,7 +392,7 @@ public sealed class ContainersServiceTests
[Fact]
public async Task PatchColState_EmptyPatch_KeepsOtherColumnsAndWritesEmptyEntry()
{
(ContainersService service, _, FakeSettingsStore settings) = Create();
(ContainersService service, _, TestSettingsStore settings) = Create();
settings.Preload("colState", """{"archive":{"collapsed":true}}""");
await service.PatchColStateAsync("inbox", new ColumnStateDto(Collapsed: null, Width: null), CancellationToken.None);
@@ -408,7 +408,7 @@ public sealed class ContainersServiceTests
[Fact]
public async Task GetColState_CorruptJson_ReturnsEmpty()
{
(ContainersService service, _, FakeSettingsStore settings) = Create();
(ContainersService service, _, TestSettingsStore settings) = Create();
settings.Preload("colState", "{oops");
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 settings = new FakeSettingsStore();
return (new ContainersService(store, settings), store, settings);
var settings = new TestSettingsStore();
return (new ContainersService(store, settings.Store), store, settings);
}
// Патч только с нужными полями: остальные параметры null = «не менять».
@@ -3,6 +3,7 @@ using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
@@ -19,7 +20,7 @@ public sealed class ConversionRecomputerTests
[Fact]
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));
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"));
@@ -34,7 +35,7 @@ public sealed class ConversionRecomputerTests
[Fact]
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.TargetCurrency, "\"EUR\""); // PATCH пишет Upper (ApplyStringKey)
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -49,7 +50,7 @@ public sealed class ConversionRecomputerTests
[Fact]
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));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -62,7 +63,7 @@ public sealed class ConversionRecomputerTests
[Fact]
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 };
settings.Preload(SettingsKeys.RatesCache, CacheJson(rates));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USDT"));
@@ -78,7 +79,7 @@ public sealed class ConversionRecomputerTests
[Fact]
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));
store.SeedCard(Card("l_board", "b_1", from: 100, to: null, cur: "USD"));
@@ -93,7 +94,7 @@ public sealed class ConversionRecomputerTests
[Fact]
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.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -109,7 +110,7 @@ public sealed class ConversionRecomputerTests
[Fact]
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));
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"));
@@ -128,7 +129,7 @@ public sealed class ConversionRecomputerTests
[Fact]
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 }));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "XXX") with
{
@@ -159,7 +160,7 @@ public sealed class ConversionRecomputerTests
[Fact]
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");
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -174,7 +175,7 @@ public sealed class ConversionRecomputerTests
[Fact]
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));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -189,7 +190,7 @@ public sealed class ConversionRecomputerTests
[Fact]
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));
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 settings = new FakeSettingsStore();
return (new ConversionRecomputer(settings, store), store, settings);
var settings = new TestSettingsStore();
return (new ConversionRecomputer(settings.Store, store), store, settings);
}
// Карточка с бюджетом в колонке (BudgetCur задан → Budget не null, как маппинг адаптера).
@@ -52,7 +52,7 @@ public sealed class GrpcAiClassifierTests
Reason = "реклама",
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.DomainDescription, Json(TestDomain));
settings.Preload(SettingsKeys.DomainKeywords, Json(TestKeywords));
@@ -88,7 +88,7 @@ public sealed class GrpcAiClassifierTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
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);
await Assert.ThrowsAsync<AiUnavailableException>(
@@ -102,7 +102,7 @@ public sealed class GrpcAiClassifierTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
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);
await classifier.FilterAsync(text, CancellationToken.None);
@@ -131,7 +131,7 @@ public sealed class GrpcAiClassifierTests
""",
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.CardPrompt, Json("Верни блок «О заявке»."));
settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain));
@@ -191,7 +191,7 @@ public sealed class GrpcAiClassifierTests
Ok = false,
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();
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) =>
{
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);
await Assert.ThrowsAsync<AiUnavailableException>(
@@ -226,7 +226,7 @@ public sealed class GrpcAiClassifierTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Заголовок","stack":[],"is_spam":false}""" };
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
(TestSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
settings.Preload(SettingsKeys.AiProvider, Json("anthropic"));
settings.Preload(
SettingsKeys.AiConfigs,
@@ -263,7 +263,7 @@ public sealed class GrpcAiClassifierTests
{
service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Т","stack":[],"is_spam":false}""" };
string text = new string('б', 6000);
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
(TestSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
await classifier.ClassifyAsync(text, CancellationToken.None);
@@ -284,7 +284,7 @@ public sealed class GrpcAiClassifierTests
// Возвращает: Экземпляр GrpcAiClassifier.
private static GrpcAiClassifier CreateClassifier(
int port,
FakeSettingsStore settings,
TestSettingsStore settings,
ISecretCipher cipher,
FakeKanjStore kanjStore,
TestTenantLimitStore? limits = null)
@@ -296,17 +296,17 @@ public sealed class GrpcAiClassifierTests
return new GrpcAiClassifier(
tenantContext,
connection,
new AiProviderConfigBuilder(settings, cipher),
new AiClassifyContextBuilder(settings, kanjStore),
new TokenUsageRecorder(settings, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
new AiProviderConfigBuilder(settings.Store, cipher),
new AiClassifyContextBuilder(settings.Store, kanjStore),
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiClassifier>.Instance);
}
// Создаёт контекст сценария (пустые фейки; сценарий переопределяет настройки/доски).
// port: Порт хоста (не используется контекстом — единый вид хелперов).
// Возвращает: Кортеж фейков (настройки, шифр, канбан).
private static (FakeSettingsStore Settings, ISecretCipher Cipher, FakeKanjStore Kanj) Context(int port)
=> (new FakeSettingsStore(), TestCiphers.New(), new FakeKanjStore());
private static (TestSettingsStore Settings, ISecretCipher Cipher, FakeKanjStore Kanj) Context(int port)
=> (new TestSettingsStore(), TestCiphers.New(), new FakeKanjStore());
// Сериализует значение настройки в JSON-строку (как пишет SettingsStore).
// value: Значение (строка/список).
@@ -314,7 +314,7 @@ public sealed class GrpcAiClassifierTests
private static string Json(object value) => JsonSerializer.Serialize(value);
private static async Task AssertUsageAsync(
FakeSettingsStore settings,
TestSettingsStore settings,
uint prompt,
uint completion,
uint total)
@@ -1,5 +1,6 @@
using System.Text.Json;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support;
@@ -12,7 +13,7 @@ public sealed class IncomingRulesTests
// Создаёт правила поверх in-memory KV-хранилища.
// store: Хранилище настроек тенанта.
// Возвращает: Экземпляр IncomingRules.
private static IncomingRules CreateRules(FakeSettingsStore store) => new(store);
private static IncomingRules CreateRules(TestSettingsStore store) => new(store.Store);
// Сериализует значение в JSON-строку строки settings (как пишет SettingsStore).
// value: Значение (строка/число/булево/список).
@@ -23,7 +24,7 @@ public sealed class IncomingRulesTests
[Fact]
public async Task CheckAsync_ShortTextBelowDefaultMinLen_FailsWithKindLength()
{
IncomingRulesResult result = await CreateRules(new FakeSettingsStore())
IncomingRulesResult result = await CreateRules(new TestSettingsStore())
.CheckAsync("Короткий текст", CancellationToken.None);
Assert.False(result.Pass);
@@ -37,7 +38,7 @@ public sealed class IncomingRulesTests
public async Task CheckAsync_EmptyText_IsLengthFail()
{
// (text or "").strip() — пустой/пробельный текст всегда «короче minLen».
IncomingRulesResult result = await CreateRules(new FakeSettingsStore())
IncomingRulesResult result = await CreateRules(new TestSettingsStore())
.CheckAsync(" ", CancellationToken.None);
Assert.False(result.Pass);
@@ -47,7 +48,7 @@ public sealed class IncomingRulesTests
[Fact]
public async Task CheckAsync_StoredMinLenShorter_AllowsTextBetweenDefaultsAndOverride()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.MinLen, Json(10));
IncomingRules rules = CreateRules(store);
@@ -61,7 +62,7 @@ public sealed class IncomingRulesTests
[Fact]
public async Task CheckAsync_LongCleanTextWithDefaults_Passes()
{
IncomingRulesResult result = await CreateRules(new FakeSettingsStore()).CheckAsync(
IncomingRulesResult result = await CreateRules(new TestSettingsStore()).CheckAsync(
"Заработок на крипте 300% в месяц! Подпишись на канал и получи бесплатный курс по трейдингу",
CancellationToken.None);
@@ -75,7 +76,7 @@ public sealed class IncomingRulesTests
[Fact]
public async Task CheckAsync_StopPhraseHit_ReturnsPhraseAsKwAndReason()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(new[] { "взаимный пиар" }));
IncomingRules rules = CreateRules(store);
@@ -92,7 +93,7 @@ public sealed class IncomingRulesTests
[Fact]
public async Task CheckAsync_StopPhrase_CaseInsensitiveMatch_KwKeepsStoredCase()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(new[] { "Взаимный Пиар" }));
IncomingRules rules = CreateRules(store);
@@ -108,7 +109,7 @@ public sealed class IncomingRulesTests
[Fact]
public async Task CheckAsync_ResumeMarker_BlockResumesOn_BlocksWithMarker()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
IncomingRules rules = CreateRules(store);
@@ -125,7 +126,7 @@ public sealed class IncomingRulesTests
[Fact]
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.BlockResumes, Json(false));
IncomingRules rules = CreateRules(store);
@@ -141,7 +142,7 @@ public sealed class IncomingRulesTests
[Fact]
public async Task CheckAsync_ResumeWordWithoutHireMarkerBefore_IsCut()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
IncomingRules rules = CreateRules(store);
@@ -158,7 +159,7 @@ public sealed class IncomingRulesTests
[Fact]
public async Task CheckAsync_VacancyWithResumeWordAfterHireMarker_GuardAllowsPass()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
IncomingRules rules = CreateRules(store);
@@ -175,7 +176,7 @@ public sealed class IncomingRulesTests
[Fact]
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.WantedType, Json("freelance"));
IncomingRules rules = CreateRules(store);
@@ -194,7 +195,7 @@ public sealed class IncomingRulesTests
[Fact]
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.WantedType, Json("vacancy"));
IncomingRules rules = CreateRules(store);
@@ -211,7 +212,7 @@ public sealed class IncomingRulesTests
[Fact]
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.WantedType, Json("freelance"));
IncomingRules rules = CreateRules(store);
@@ -229,7 +230,7 @@ public sealed class IncomingRulesTests
public async Task CheckAsync_WantedTypeBoth_VacancyText_Passes()
{
// Дефолт wantedType=both: и вакансии, и заказы — вакансионный текст не отсекается.
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
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.Services;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
@@ -17,11 +18,11 @@ public sealed class LocalColumnSuggesterTests
{
// Создаёт контекст теста: in-memory хранилища + адаптер поверх сервиса досок модуля.
// Возвращает: Кортеж (хранилище канбана, KV-настройки, адаптер).
private static (FakeKanjStore Store, FakeSettingsStore Settings, LocalColumnSuggester Suggester) CreateContext()
private static (FakeKanjStore Store, TestSettingsStore Settings, LocalColumnSuggester Suggester) CreateContext()
{
var store = new FakeKanjStore();
var settings = new FakeSettingsStore();
var suggester = new LocalColumnSuggester(store, settings, new ContainersService(store, settings));
var settings = new TestSettingsStore();
var suggester = new LocalColumnSuggester(store, settings.Store, new ContainersService(store, settings.Store));
return (store, settings, suggester);
}
@@ -72,7 +73,7 @@ public sealed class LocalColumnSuggesterTests
[Fact]
public async Task SuggestColumns_CooldownActive_ReturnsCooldownReason()
{
(FakeKanjStore store, FakeSettingsStore settings, LocalColumnSuggester suggester) = CreateContext();
(FakeKanjStore store, TestSettingsStore settings, LocalColumnSuggester suggester) = CreateContext();
settings.Preload(SettingsKeys.LastSuggestAt,
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
SeedInbox("l_1", "нужен python", store);
@@ -128,7 +129,7 @@ public sealed class LocalColumnSuggesterTests
[Fact]
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_p2", "нужен python", store);
SeedInbox("l_p3", "нужен python", store);
@@ -3,6 +3,7 @@ using Deal.Contracts.Integrations.Models;
using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings;
@@ -27,14 +28,14 @@ public sealed class LocalMlClientTests
// store: Хранилище настроек тенанта.
// learning: Хранилище обучения (по умолчанию пустое: счётчики 0).
// Возвращает: Экземпляр LocalMlClient.
private static LocalMlClient CreateClient(FakeSettingsStore store, FakeMlLearningStore? learning = null)
=> new(store, learning ?? new FakeMlLearningStore());
private static LocalMlClient CreateClient(TestSettingsStore store, FakeMlLearningStore? learning = null)
=> new(store.Store, learning ?? new FakeMlLearningStore());
[Fact]
public async Task StatusAsync_NoSettings_ReturnsDeterministicNotReadyStatus()
{
LocalMlClient client = CreateClient(new FakeSettingsStore());
LocalMlClient client = CreateClient(new TestSettingsStore());
MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None);
@@ -63,7 +64,7 @@ public sealed class LocalMlClientTests
[Fact]
public async Task StatusAsync_StoredCounters_AreReflectedInStats()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlDecisions, "7");
store.Preload(SettingsKeys.AiDecisions, "3");
LocalMlClient client = CreateClient(store);
@@ -78,7 +79,7 @@ public sealed class LocalMlClientTests
[Fact]
public async Task StatusAsync_StoredMlEnabledFalse_Disables()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlEnabled, "false");
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
@@ -89,7 +90,7 @@ public sealed class LocalMlClientTests
[Fact]
public async Task StatusAsync_StoredMlEnabledTrue_Enables()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlEnabled, "true");
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
@@ -102,7 +103,7 @@ public sealed class LocalMlClientTests
[InlineData("не json {{")]
public async Task StatusAsync_NonBoolOrDamagedMlEnabled_DegradesToDefault(string valueJson)
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlEnabled, valueJson);
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
@@ -113,7 +114,7 @@ public sealed class LocalMlClientTests
[Fact]
public async Task StatusAsync_DamagedCounter_DegradesToZero()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlDecisions, "не число");
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
@@ -124,7 +125,7 @@ public sealed class LocalMlClientTests
[Fact]
public async Task StatusAsync_ReportsLearningAndOutboxFromTables()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
var learning = new FakeMlLearningStore { LearningCount = 3 }; // журнал CardMoves: 3 записи
LocalMlClient client = CreateClient(store, learning);
await client.PushAsync("Разработчик Python, готов к проекту", "b_alpha", 1.0, CancellationToken.None);
@@ -142,7 +143,7 @@ public sealed class LocalMlClientTests
[Fact]
public async Task PredictAsync_AnyText_ReturnsFixedNotReadyShape()
{
LocalMlClient client = CreateClient(new FakeSettingsStore());
LocalMlClient client = CreateClient(new TestSettingsStore());
MlPredictResultDto result = await client.PredictAsync(
"Python backend на fastapi, бот в телеграм", CancellationToken.None);
@@ -162,7 +163,7 @@ public sealed class LocalMlClientTests
public async Task PushAsync_WritesOutboxRow_WithMleIdAndTrimmedFields()
{
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);
@@ -182,7 +183,7 @@ public sealed class LocalMlClientTests
public async Task PushAsync_EmptyTextOrLabelAfterTrim_IsNoOp(string text, string label)
{
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);
@@ -193,7 +194,7 @@ public sealed class LocalMlClientTests
public async Task PushAsync_TextOver6000_IsTruncatedToMaxLength()
{
var learning = new FakeMlLearningStore();
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning);
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
string longText = new string('а', MaxLearningTextLength + 1000);
await client.PushAsync(longText, "spam", 1.0, CancellationToken.None);
@@ -207,7 +208,7 @@ public sealed class LocalMlClientTests
public async Task PushAsync_TruncationDoesNotSplitSurrogatePair()
{
var learning = new FakeMlLearningStore();
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning);
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
// Длина 6001 в UTF-16: 5999 'x' + «🙂» (суррогатная пара) — срез .NET на 6000 попадает в пару.
string text = new string('x', MaxLearningTextLength - 1) + "🙂";
@@ -223,7 +224,7 @@ public sealed class LocalMlClientTests
public async Task PushAsync_NegativeDelta_IsStoredAsIs()
{
var learning = new FakeMlLearningStore();
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning);
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
await client.PushAsync("Это не спам — вернул из корзины", "spam", -1.0, CancellationToken.None);
@@ -236,7 +237,7 @@ public sealed class LocalMlClientTests
[Fact]
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.Null(result.Error);
@@ -245,7 +246,7 @@ public sealed class LocalMlClientTests
[Fact]
public async Task ResetAsync_DoesNotTouchSettingsRows()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlDecisions, "7");
LocalMlClient client = CreateClient(store);
@@ -259,7 +260,7 @@ public sealed class LocalMlClientTests
[Fact]
public async Task ResetAsync_ClearsOutbox_KeepsLearningJournalAndKvCounters()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlDecisions, "7");
var learning = new FakeMlLearningStore { LearningCount = 4 }; // журнал CardMoves: 4 записи
LocalMlClient client = CreateClient(store, learning);
@@ -278,7 +279,7 @@ public sealed class LocalMlClientTests
[Fact]
public async Task MlStatusResponseDto_SerializesToPrototypeWireFormat()
{
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.MlEnabled, "false");
store.Preload(SettingsKeys.MlDecisions, "7");
store.Preload(SettingsKeys.AiDecisions, "3");
@@ -316,7 +317,7 @@ public sealed class LocalMlClientTests
[Fact]
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);
@@ -329,7 +330,7 @@ public sealed class LocalMlClientTests
[Fact]
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);
@@ -3,6 +3,7 @@ using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support;
@@ -428,10 +429,10 @@ public sealed class MessageParseCoreTests
public async Task ParseAsync_ReadsMarkerOverridesFromSettings()
{
// Кастомные hireMarkers из хранилища перекрывают дефолты: «вакансия» больше не маркер, свой — маркер.
var store = new FakeSettingsStore();
var store = new TestSettingsStore();
store.Preload(SettingsKeys.HireMarkers, JsonSerializer.Serialize(new[] { "нанимаем джунов" }));
LocalFieldsParser parser = new(store);
LocalFieldsParser parser = new(store.Store);
LocalParsedFields custom = await parser.ParseAsync("Срочно нанимаем джунов на проект", default);
LocalParsedFields withoutMarker = await parser.ParseAsync("Вакансия Java-разработчика на проект", default);
@@ -442,7 +443,7 @@ public sealed class MessageParseCoreTests
[Fact]
public async Task ParseAsync_NoOverrides_UsesDefaults()
{
LocalFieldsParser parser = new(new FakeSettingsStore());
LocalFieldsParser parser = new(new TestSettingsStore().Store);
LocalParsedFields result = await parser.ParseAsync("Вакансия в команду на проект", default);
@@ -155,8 +155,8 @@ public sealed class PipelineWorkerSchedulerTests
var pipelineB = new FakePipelineStore();
var kanjA = new FakeKanjStore();
var kanjB = new FakeKanjStore();
var settingsA = new FakeSettingsStore();
var settingsB = new FakeSettingsStore();
var settingsA = new TestSettingsStore();
var settingsB = new TestSettingsStore();
var pumpGate = new PipelinePumpGate();
var aiClassifier = new TestAiClassifier
{
@@ -173,7 +173,7 @@ public sealed class PipelineWorkerSchedulerTests
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
// (эталон StorageTickSchedulerTests/ConnectionStringProvider.ForTenant).
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);
// Реальные сервисы модуля Pipeline — как AddPipelineModule в Program.cs: цикл резолвит их в tenant-scope.
services.AddScoped<IncomingRules>();
@@ -27,7 +27,7 @@ public sealed class PipelineWorkerServiceTests
PipelineWorkerService Worker,
TestPipelineStore PipelineStore,
FakeKanjStore KanjStore,
FakeSettingsStore Settings,
TestSettingsStore Settings,
TestMlClient MlClient,
TestAiClassifier AiClassifier);
@@ -36,18 +36,18 @@ public sealed class PipelineWorkerServiceTests
// Возвращает: Воркер и фейки (сценарий до-настраивает строки очереди/ответы портов).
private static Context CreateContext(TestPipelineStore? pipelineStore = null)
{
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
var store = pipelineStore ?? new TestPipelineStore();
var kanjStore = new FakeKanjStore();
var mlClient = new TestMlClient { Predict = NotReadyPrediction() };
var aiClassifier = new TestAiClassifier();
var rules = new IncomingRules(settings);
var fieldsParser = new LocalFieldsParser(settings);
var rules = new IncomingRules(settings.Store);
var fieldsParser = new LocalFieldsParser(settings.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 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);
}
@@ -1,6 +1,7 @@
using System.Text.Json;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Settings;
using NSubstitute;
@@ -14,7 +15,7 @@ public sealed class RatesServiceTests
{
private const long FixedTimestampMs = 1_752_000_000_000;
private readonly FakeSettingsStore _store = new();
private readonly TestSettingsStore _store = new();
// ─── GetAsync: кэш и дефолт ─────────────────────────────────────────────
@@ -418,7 +419,7 @@ public sealed class RatesServiceTests
private RatesService CreateService(IRatesSource source, IReadOnlyList<IRatesChangedListener> listeners)
{
return new RatesService(_store, source, listeners);
return new RatesService(_store.Store, source, listeners);
}
// JSON значения ratesCache с одним курсом USD и заданным временем/источником.
@@ -1,6 +1,7 @@
using System.Text.Json;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Settings;
using NSubstitute;
@@ -12,13 +13,13 @@ namespace Deal.Tests.Unit.Support;
/// </summary>
public sealed class SettingsServiceTests
{
private readonly FakeSettingsStore _store = new();
private readonly TestSettingsStore _store = new();
private readonly ISecretCipher _cipher = TestCiphers.New();
private readonly SettingsService _service;
public SettingsServiceTests()
{
_service = new SettingsService(_store, _cipher, Array.Empty<IRatesChangedListener>());
_service = new SettingsService(_store.Store, _cipher, Array.Empty<IRatesChangedListener>());
}
// ─── GET: снимок ─────────────────────────────────────────────────────────
@@ -592,7 +593,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_TargetCurrencyInBody_NotifiesListenersAfterSave()
{
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);
@@ -605,7 +606,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_ConversionOnInBody_NotifiesListeners()
{
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);
@@ -617,7 +618,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_UnrelatedKeys_DoesNotNotifyListeners()
{
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);
@@ -628,7 +629,7 @@ public sealed class SettingsServiceTests
public async Task ApplyPatchAsync_TargetCurrencyJsonNull_DoesNotNotifyListeners()
{
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);
@@ -11,6 +11,7 @@ using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants.Abstractions;
@@ -44,7 +45,7 @@ public sealed class StorageTickSchedulerTests
{
FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old");
FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(tenants, tenantContext, storeA, storeB, settings);
@@ -73,7 +74,7 @@ public sealed class StorageTickSchedulerTests
FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old");
var storeB = new FakeKanjStore();
storeB.SeedCard(Card("l_b_fresh", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromHours(1))));
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)),
@@ -104,7 +105,7 @@ public sealed class StorageTickSchedulerTests
var settingsByTenant = new Dictionary<Guid, ISettingsStore>
{
[TenantA] = new ThrowingSettingsStore(),
[TenantB] = new FakeSettingsStore(),
[TenantB] = new TestSettingsStore().Store,
};
var storesByTenant = new Dictionary<Guid, FakeKanjStore>
{
@@ -151,7 +152,7 @@ public sealed class StorageTickSchedulerTests
public async Task RunCycle_PurgesExpiredRejectedRowsAndPublishesRejectedPurgeToast()
{
var kanjStore = new FakeKanjStore();
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
var tenantContext = new TenantContext();
var pipelineStoreA = new TestPipelineStore();
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
@@ -161,7 +162,7 @@ public sealed class StorageTickSchedulerTests
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
tenantContext,
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() });
SseBroker broker = provider.GetRequiredService<SseBroker>();
@@ -191,7 +192,7 @@ public sealed class StorageTickSchedulerTests
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
tenantContext,
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() });
SseBroker broker = provider.GetRequiredService<SseBroker>();
@@ -219,14 +220,14 @@ public sealed class StorageTickSchedulerTests
{
var cardStoreA = new FakeKanjStore();
cardStoreA.SeedCard(HoldCard("c_past", title: "Старое", reminderAtMs: NowMs() - 60_000));
var settingsA = new FakeSettingsStore();
var settingsA = new TestSettingsStore();
settingsA.Preload(SettingsKeys.RemindersEnabled, "false");
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA)).Repository,
tenantContext,
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() });
SseBroker broker = provider.GetRequiredService<SseBroker>();
@@ -252,7 +253,7 @@ public sealed class StorageTickSchedulerTests
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
tenantContext,
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() });
SseBroker broker = provider.GetRequiredService<SseBroker>();
@@ -283,13 +284,13 @@ public sealed class StorageTickSchedulerTests
TenantContext tenantContext,
FakeKanjStore storeA,
FakeKanjStore storeB,
FakeSettingsStore settings)
TestSettingsStore settings)
{
return BuildProvider(
tenants.Repository,
tenantContext,
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-провайдер теста по явным картам тенант → хранилище/настройки.
@@ -3,6 +3,7 @@ using Deal.Api.Events;
using Deal.Grpc.Telegram;
using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Telegram.Application;
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
@@ -61,7 +62,7 @@ public sealed class TelegramIngressServiceTests
public async Task SyncDialogs_AutoMonitorNewDisabled_ReturnsEmptyMonitoredMirror()
{
var registry = TestRegistry.With(Tenant(TenantA));
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
settings.Preload(SettingsKeys.AutoMonitorNew, "false");
var dialogs = new TestTelegramStore();
@@ -69,7 +70,7 @@ public sealed class TelegramIngressServiceTests
registry,
services =>
{
services.AddScoped<ISettingsStore>(_ => settings);
services.AddScoped<ISettingsStore>(_ => settings.Store);
services.AddScoped<ITelegramStore>(_ => dialogs.Store);
},
async channel =>
@@ -91,7 +92,7 @@ public sealed class TelegramIngressServiceTests
[Fact]
public async Task ReportStatus_PersistsStatusAndPublishesTransitionToasts()
{
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
var registry = TestRegistry.With(Tenant(TenantA));
var broker = new SseBroker();
@@ -100,7 +101,7 @@ public sealed class TelegramIngressServiceTests
services =>
{
services.AddSingleton<SseBroker>(broker);
services.AddScoped<ISettingsStore>(_ => settings);
services.AddScoped<ISettingsStore>(_ => settings.Store);
},
async channel =>
{
@@ -138,12 +139,12 @@ public sealed class TelegramIngressServiceTests
[Fact]
public async Task ReportStatus_UnknownTenant_NotSavedWithoutRpcError()
{
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
var registry = TestRegistry.With(Tenant(TenantA));
await RunAsync(
registry,
services => services.AddScoped<ISettingsStore>(_ => settings),
services => services.AddScoped<ISettingsStore>(_ => settings.Store),
async channel =>
{
var client = new IngressService.IngressServiceClient(channel);
@@ -261,7 +262,7 @@ public sealed class TelegramIngressServiceTests
// phase: Ожидаемая фаза.
// connected: Ожидаемый флаг connected.
private static void AssertStatusStored(
FakeSettingsStore settings,
TestSettingsStore settings,
string phase,
bool connected)
{
@@ -99,7 +99,7 @@ internal static class TelegramIngressTestHost
.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<ITelegramGateway>(_ => new TestTelegramGateway().Gateway);
builder.Services.AddTelegramModule();
@@ -71,7 +71,7 @@ public sealed class TelegramSourceIngestObserverTests
var store = new TestTelegramStore();
var dialogs = new DialogsService(
store.Store,
new FakeSettingsStore(),
new TestSettingsStore().Store,
new TestTelegramGateway().Gateway,
NullLogger<DialogsService>.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]
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);
@@ -52,7 +52,7 @@ public sealed class TokenUsageRecorderTests
[Fact]
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 = 300, Completion = 60, Total = 360 }, "deepseek", "m", CancellationToken.None);
@@ -68,7 +68,7 @@ public sealed class TokenUsageRecorderTests
[Fact]
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);
@@ -80,7 +80,7 @@ public sealed class TokenUsageRecorderTests
[Fact]
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 и событие пишутся (вызов состоялся), строка лимита
// нулевым расходом не заводится — ленивый GetOrCreate остаётся первому реальному списанию/чтению.
@@ -96,11 +96,11 @@ public sealed class TokenUsageRecorderTests
[Fact]
public async Task AddAsync_OutsideTenantContext_Throws()
{
var settings = new FakeSettingsStore();
var settings = new TestSettingsStore();
var limits = new TestTenantLimitStore();
ITenantContext tenantContext = new TenantContext(); // без SetTenant — списание вне tenant-контекста невозможно.
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>(
() => recorder.AddAsync(new Usage { Prompt = 1, Completion = 1, Total = 2 }, "deepseek", "m", CancellationToken.None));
@@ -111,7 +111,7 @@ public sealed class TokenUsageRecorderTests
[Fact]
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-счётчик не трогаются.
long estimated = await recorder.AddEstimatedAsync(
@@ -141,14 +141,14 @@ public sealed class TokenUsageRecorderTests
// Создаёт recorder сценария: tenant-контекст + фейки KV/лимитов/истории.
// Возвращает: Кортеж (настройки, лимиты, история, 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 events = new TestTokenUsageEventStore();
ITenantContext tenantContext = new TenantContext();
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);
}
}