diff --git a/src/core/tests/Deal.Tests.Unit/Api/DiscoveryWorkerSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Api/DiscoveryWorkerSchedulerTests.cs index 81cc408..0b7f3f6 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/DiscoveryWorkerSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/DiscoveryWorkerSchedulerTests.cs @@ -6,6 +6,7 @@ using Deal.Modules.Discovery.Application.Abstractions; using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Registrars; using Deal.Modules.Settings.Application.Abstractions; +using Deal.Tests.Unit.Support; using Deal.Modules.Settings.Application.Models; using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Models; @@ -104,7 +105,7 @@ public sealed class DiscoveryWorkerSchedulerTests services.AddSingleton(tenantContext); services.AddSingleton(tenants); services.AddSingleton(new FakeMlClient()); - services.AddSingleton(new FakeAiTools()); + services.AddSingleton(_ => TestAiTools.New()); // Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant // (эталон PipelineWorkerSchedulerTests/ConnectionStringProvider.ForTenant). services.AddScoped(provider => TenantOf(provider) == TenantA ? storeA : storeB); diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiToolsTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiToolsTests.cs index 772bc70..daac8df 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiToolsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/BudgetedAiToolsTests.cs @@ -5,6 +5,8 @@ using Deal.Infrastructure.Integrations.Services; using Deal.Modules.Tenants.Application.Models; using Deal.SharedKernel.Tenants.Models; using Deal.Tests.Unit.Modules.Tenants; +using Deal.Tests.Unit.Support; +using NSubstitute; using Microsoft.Extensions.Logging.Abstractions; using Deal.Contracts.Integrations.Abstractions; using Deal.SharedKernel.Tenants.Abstractions; @@ -31,12 +33,12 @@ public sealed class BudgetedAiToolsTests Context ctx = Create( limits => limits.Preload( TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 1000)); - ctx.Paid.Fit = new AiEvaluateFitResultDto(Fit: true, Reason: "подходит"); + ctx.Paid.ReturnsFit(new AiEvaluateFitResultDto(Fit: true, Reason: "подходит")); AiUnavailableException exception = await Assert.ThrowsAsync( () => ctx.Decorator.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None)); - Assert.Equal(0, ctx.Paid.EvaluateFitCalls); + await ctx.Paid.DidNotReceive().EvaluateFitAsync(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()); Assert.Contains("исчерпан", exception.Message); } @@ -49,7 +51,7 @@ public sealed class BudgetedAiToolsTests await Assert.ThrowsAsync( () => ctx.Decorator.EvaluateFitAsync("текст", "описание", Array.Empty(), CancellationToken.None)); - Assert.Equal(0, ctx.Paid.EvaluateFitCalls); + await ctx.Paid.DidNotReceive().EvaluateFitAsync(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()); } [Fact] @@ -63,7 +65,7 @@ public sealed class BudgetedAiToolsTests AiUnavailableException exception = await Assert.ThrowsAsync( () => ctx.Decorator.EvaluateFitAsync("текст", "описание", Array.Empty(), CancellationToken.None)); - Assert.Equal(0, ctx.Paid.EvaluateFitCalls); + await ctx.Paid.DidNotReceive().EvaluateFitAsync(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()); Assert.Contains("приостановлен", exception.Message); } @@ -72,13 +74,13 @@ public sealed class BudgetedAiToolsTests { // Строки лимита нет — ленивый дефолт (10 000 000/месяц, расход 0) → Allowed=true → платный вызов. Context ctx = Create(); - ctx.Paid.Fit = new AiEvaluateFitResultDto(Fit: false, Reason: "не подходит"); + ctx.Paid.ReturnsFit(new AiEvaluateFitResultDto(Fit: false, Reason: "не подходит")); AiEvaluateFitResultDto result = await ctx.Decorator.EvaluateFitAsync( "текст", "описание", new[] { "ключ" }, CancellationToken.None); Assert.False(result.Fit); - Assert.Equal(1, ctx.Paid.EvaluateFitCalls); + await ctx.Paid.Received(1).EvaluateFitAsync(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()); } [Fact] @@ -116,7 +118,7 @@ public sealed class BudgetedAiToolsTests public async Task GenerateKeywords_LargeBudget_DelegatesToPaid() { // Строки лимита нет — ленивый дефолт → Allowed=true → декоратор делегирует платному исполнителю. - // FakeAiTools.GenerateKeywordsAsync всегда бросает NotSupportedException (вызов не ждали) — само + // Подставка бросает NotSupportedException на GenerateKeywordsAsync (вызов не ждали) — само // исключение здесь и есть признак делегирования (упало бы в тесте, если бы декоратор не позвал платный). Context ctx = Create(); @@ -131,7 +133,7 @@ public sealed class BudgetedAiToolsTests // Paid: Платный фейк-исполнитель (счётчик/ответы сценария). private sealed record Context( IAiTools Decorator, - FakeAiTools Paid); + IAiTools Paid); // Собирает контекст: платный фейк + фейк лимитов и tenant-контекст (как регистрирует // AddDealIntegrations при UseLocal=false). @@ -143,7 +145,7 @@ public sealed class BudgetedAiToolsTests configure?.Invoke(limits); ITenantContext tenantContext = new TenantContext(); tenantContext.SetTenant(new TenantId(TenantIdValue)); - var paid = new FakeAiTools(); + var paid = TestAiTools.New(); IAiTools decorator = new BudgetedAiTools( paid, limits, diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryEvaluatorTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryEvaluatorTests.cs index a4072f7..3520c6e 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryEvaluatorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryEvaluatorTests.cs @@ -1,8 +1,11 @@ +using Deal.Contracts.Integrations.Abstractions; using Deal.Contracts.Integrations.Models; using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Services; using Deal.Modules.Settings.Application.Models; using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Support; +using NSubstitute; namespace Deal.Tests.Unit.Contracts; @@ -43,8 +46,8 @@ public sealed class DiscoveryEvaluatorTests [Fact] public async Task EvaluateMessage_AiFits_ReturnsAiVerdict() { - (DiscoveryEvaluator evaluator, _, FakeMlClient ml, FakeAiTools ai) = Create(aiEnabled: true, mlEnabled: false); - ai.Fit = new AiEvaluateFitResultDto(Fit: true, "тема совпадает"); + (DiscoveryEvaluator evaluator, _, FakeMlClient ml, IAiTools ai) = Create(aiEnabled: true, mlEnabled: false); + ai.ReturnsFit(new AiEvaluateFitResultDto(Fit: true, "тема совпадает")); DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync( TaskDto(keywords: ["заказ"]), "Нужен заказ на разработку CRM для агентства недвижимости", CancellationToken.None); @@ -52,14 +55,14 @@ public sealed class DiscoveryEvaluatorTests Assert.True(fit.Fit); Assert.Equal("тема совпадает", fit.Reason); Assert.Equal("ai", fit.Source); - Assert.Equal(1, ai.EvaluateFitCalls); + await ai.Received(1).EvaluateFitAsync(Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()); } [Fact] public async Task EvaluateMessage_AiFailure_FallsBackToHeuristic() { - (DiscoveryEvaluator evaluator, _, _, FakeAiTools ai) = Create(aiEnabled: true, mlEnabled: false); - ai.Error = new InvalidOperationException("ai-service недоступен"); // как gRPC AiUnavailableException + (DiscoveryEvaluator evaluator, _, _, IAiTools ai) = Create(aiEnabled: true, mlEnabled: false); + ai.ThrowsOnEvaluate(new InvalidOperationException("ai-service недоступен")); // как gRPC AiUnavailableException DiscoveryTaskDto task = TaskDto(keywords: ["заказ"]); DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync( @@ -165,13 +168,13 @@ public sealed class DiscoveryEvaluatorTests => new(id, text, DateMs: 1_700_000_000_000 + id, topic, TopicTitle: null); // Создаёт оценщик на фейках: флаги веток выставляются сценарием. - private static (DiscoveryEvaluator Evaluator, FakeSettingsStore Settings, FakeMlClient Ml, FakeAiTools Ai) Create(bool aiEnabled, bool mlEnabled) + private static (DiscoveryEvaluator Evaluator, FakeSettingsStore Settings, FakeMlClient Ml, IAiTools Ai) Create(bool aiEnabled, bool mlEnabled) { var settings = new FakeSettingsStore(); settings.Preload(SettingsKeys.AiEnabled, aiEnabled ? "true" : "false"); settings.Preload(SettingsKeys.MlEnabled, mlEnabled ? "true" : "false"); var ml = new FakeMlClient(); - var ai = new FakeAiTools(); + var ai = TestAiTools.New(); return (new DiscoveryEvaluator(settings, ml, ai), settings, ml, ai); } diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs index b169ce5..0bc227f 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/DiscoveryWorkerServiceTests.cs @@ -5,6 +5,7 @@ using Deal.Tests.Unit.Modules.Discovery; using Deal.Modules.Discovery.Application.Services; using Deal.Modules.Settings.Application.Models; using Deal.Tests.Unit.Modules.Settings; +using Deal.Tests.Unit.Support; using NSubstitute; using Grpc.Core; @@ -432,7 +433,7 @@ public sealed class DiscoveryWorkerServiceTests settings.Preload(SettingsKeys.MlEnabled, "false"); settings.Preload(SettingsKeys.AiEnabled, "false"); var ml = new FakeMlClient(); - var ai = new FakeAiTools(); + var ai = TestAiTools.New(); var log = new DiscoveryLogService(store); var blacklist = new DiscoveryBlacklistService(store); var candidates = new DiscoveryCandidatesService(store, log, blacklist); diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/FakeAiTools.cs b/src/core/tests/Deal.Tests.Unit/Contracts/FakeAiTools.cs deleted file mode 100644 index 4f40835..0000000 --- a/src/core/tests/Deal.Tests.Unit/Contracts/FakeAiTools.cs +++ /dev/null @@ -1,47 +0,0 @@ -using Deal.Contracts.Integrations.Abstractions; -using Deal.Contracts.Integrations.Models; - -namespace Deal.Tests.Unit.Contracts; - -/// -/// Фейковая реализация для тестов Discovery-воркера/оценки. -/// -public sealed class FakeAiTools : IAiTools -{ - /// - /// Ответ EvaluateFitAsync; null — вызов бросит - /// - public AiEvaluateFitResultDto? Fit { get; set; } - - /// - /// Явный сбой EvaluateFitAsync - /// - public Exception? Error { get; set; } - - /// - /// Сколько раз вызван EvaluateFitAsync - /// - public int EvaluateFitCalls { get; private set; } - - /// - public Task EvaluateFitAsync( - string text, - string description, - IReadOnlyCollection keywords, - CancellationToken ct) - { - EvaluateFitCalls++; - if (Error is not null) - { - throw Error; - } - - return Fit is null - ? throw new NotSupportedException() - : Task.FromResult(Fit); - } - - /// - public Task GenerateKeywordsAsync(string description, CancellationToken ct) - => throw new NotSupportedException(); -} diff --git a/src/core/tests/Deal.Tests.Unit/Support/TestAiTools.cs b/src/core/tests/Deal.Tests.Unit/Support/TestAiTools.cs new file mode 100644 index 0000000..5c9ddbe --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Support/TestAiTools.cs @@ -0,0 +1,51 @@ +using Deal.Contracts.Integrations.Abstractions; +using Deal.Contracts.Integrations.Models; +using NSubstitute; + +namespace Deal.Tests.Unit.Support; + +/// +/// Подставка для сценариев Discovery/бюджетного гейта. +/// +public static class TestAiTools +{ + /// + /// Создаёт подставку: по умолчанию оба метода бросают — + /// тесты переопределяют только то, что ждут (как раньше делал фейк). + /// + public static IAiTools New() => Configure(Substitute.For()); + + /// + /// Настраивает «запрещённые по умолчанию» методы подставки. + /// + /// Подставка инструментов ИИ. + public static IAiTools Configure(IAiTools ai) + { + ai.EvaluateFitAsync( + Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(_ => throw new NotSupportedException()); + ai.GenerateKeywordsAsync(Arg.Any(), Arg.Any()) + .Returns(_ => throw new NotSupportedException()); + return ai; + } + + /// + /// Сценарий: возвращает заданный результат. + /// + /// Подставка инструментов ИИ. + /// Результат оценки соответствия. + public static void ReturnsFit(this IAiTools ai, AiEvaluateFitResultDto fit) => + ai.EvaluateFitAsync( + Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(fit); + + /// + /// Сценарий: бросает заданное исключение. + /// + /// Подставка инструментов ИИ. + /// Исключение для вызова. + public static void ThrowsOnEvaluate(this IAiTools ai, Exception error) => + ai.EvaluateFitAsync( + Arg.Any(), Arg.Any(), Arg.Any>(), Arg.Any()) + .Returns(_ => throw error); +}