Перевести FakeAiTools на NSubstitute
ci / build-test (push) Canceled after 0s

Хелпер Support/TestAiTools: по умолчанию оба метода бросают NotSupportedException
(семантика прежнего фейка — неожиданный вызов роняет тест), сценарии через
ReturnsFit/ThrowsOnEvaluate, счётчик EvaluateFitCalls -> Received/DidNotReceive.
Потребители (4 файла) перетипизированы на IAiTools, фейк удалён, тесты 1340.
This commit is contained in:
Rustam Khalimov
2026-09-12 20:23:41 +03:00
parent e3ade19353
commit 28b9777fa0
6 changed files with 76 additions and 65 deletions
@@ -6,6 +6,7 @@ using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Discovery.Application.Registrars; using Deal.Modules.Discovery.Application.Registrars;
using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Abstractions;
using Deal.Tests.Unit.Support;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Tenants.Application.Abstractions; using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
@@ -104,7 +105,7 @@ public sealed class DiscoveryWorkerSchedulerTests
services.AddSingleton<ITenantContext>(tenantContext); services.AddSingleton<ITenantContext>(tenantContext);
services.AddSingleton<ITenantRepository>(tenants); services.AddSingleton<ITenantRepository>(tenants);
services.AddSingleton<IMlClient>(new FakeMlClient()); services.AddSingleton<IMlClient>(new FakeMlClient());
services.AddSingleton<IAiTools>(new FakeAiTools()); services.AddSingleton<IAiTools>(_ => TestAiTools.New());
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant // Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
// (эталон PipelineWorkerSchedulerTests/ConnectionStringProvider.ForTenant). // (эталон PipelineWorkerSchedulerTests/ConnectionStringProvider.ForTenant).
services.AddScoped<IDiscoveryStore>(provider => TenantOf(provider) == TenantA ? storeA : storeB); services.AddScoped<IDiscoveryStore>(provider => TenantOf(provider) == TenantA ? storeA : storeB);
@@ -5,6 +5,8 @@ using Deal.Infrastructure.Integrations.Services;
using Deal.Modules.Tenants.Application.Models; using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants.Models; using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Modules.Tenants; using Deal.Tests.Unit.Modules.Tenants;
using Deal.Tests.Unit.Support;
using NSubstitute;
using Microsoft.Extensions.Logging.Abstractions; using Microsoft.Extensions.Logging.Abstractions;
using Deal.Contracts.Integrations.Abstractions; using Deal.Contracts.Integrations.Abstractions;
using Deal.SharedKernel.Tenants.Abstractions; using Deal.SharedKernel.Tenants.Abstractions;
@@ -31,12 +33,12 @@ public sealed class BudgetedAiToolsTests
Context ctx = Create( Context ctx = Create(
limits => limits.Preload( limits => limits.Preload(
TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, PeriodStart, usedTokens: 1000)); 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<AiUnavailableException>( AiUnavailableException exception = await Assert.ThrowsAsync<AiUnavailableException>(
() => ctx.Decorator.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None)); () => ctx.Decorator.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None));
Assert.Equal(0, ctx.Paid.EvaluateFitCalls); await ctx.Paid.DidNotReceive().EvaluateFitAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>());
Assert.Contains("исчерпан", exception.Message); Assert.Contains("исчерпан", exception.Message);
} }
@@ -49,7 +51,7 @@ public sealed class BudgetedAiToolsTests
await Assert.ThrowsAsync<AiUnavailableException>( await Assert.ThrowsAsync<AiUnavailableException>(
() => ctx.Decorator.EvaluateFitAsync("текст", "описание", Array.Empty<string>(), CancellationToken.None)); () => ctx.Decorator.EvaluateFitAsync("текст", "описание", Array.Empty<string>(), CancellationToken.None));
Assert.Equal(0, ctx.Paid.EvaluateFitCalls); await ctx.Paid.DidNotReceive().EvaluateFitAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
@@ -63,7 +65,7 @@ public sealed class BudgetedAiToolsTests
AiUnavailableException exception = await Assert.ThrowsAsync<AiUnavailableException>( AiUnavailableException exception = await Assert.ThrowsAsync<AiUnavailableException>(
() => ctx.Decorator.EvaluateFitAsync("текст", "описание", Array.Empty<string>(), CancellationToken.None)); () => ctx.Decorator.EvaluateFitAsync("текст", "описание", Array.Empty<string>(), CancellationToken.None));
Assert.Equal(0, ctx.Paid.EvaluateFitCalls); await ctx.Paid.DidNotReceive().EvaluateFitAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>());
Assert.Contains("приостановлен", exception.Message); Assert.Contains("приостановлен", exception.Message);
} }
@@ -72,13 +74,13 @@ public sealed class BudgetedAiToolsTests
{ {
// Строки лимита нет — ленивый дефолт (10 000 000/месяц, расход 0) → Allowed=true → платный вызов. // Строки лимита нет — ленивый дефолт (10 000 000/месяц, расход 0) → Allowed=true → платный вызов.
Context ctx = Create(); 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( AiEvaluateFitResultDto result = await ctx.Decorator.EvaluateFitAsync(
"текст", "описание", new[] { "ключ" }, CancellationToken.None); "текст", "описание", new[] { "ключ" }, CancellationToken.None);
Assert.False(result.Fit); Assert.False(result.Fit);
Assert.Equal(1, ctx.Paid.EvaluateFitCalls); await ctx.Paid.Received(1).EvaluateFitAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
@@ -116,7 +118,7 @@ public sealed class BudgetedAiToolsTests
public async Task GenerateKeywords_LargeBudget_DelegatesToPaid() public async Task GenerateKeywords_LargeBudget_DelegatesToPaid()
{ {
// Строки лимита нет — ленивый дефолт → Allowed=true → декоратор делегирует платному исполнителю. // Строки лимита нет — ленивый дефолт → Allowed=true → декоратор делегирует платному исполнителю.
// FakeAiTools.GenerateKeywordsAsync всегда бросает NotSupportedException (вызов не ждали) — само // Подставка бросает NotSupportedException на GenerateKeywordsAsync (вызов не ждали) — само
// исключение здесь и есть признак делегирования (упало бы в тесте, если бы декоратор не позвал платный). // исключение здесь и есть признак делегирования (упало бы в тесте, если бы декоратор не позвал платный).
Context ctx = Create(); Context ctx = Create();
@@ -131,7 +133,7 @@ public sealed class BudgetedAiToolsTests
// Paid: Платный фейк-исполнитель (счётчик/ответы сценария). // Paid: Платный фейк-исполнитель (счётчик/ответы сценария).
private sealed record Context( private sealed record Context(
IAiTools Decorator, IAiTools Decorator,
FakeAiTools Paid); IAiTools Paid);
// Собирает контекст: платный фейк + фейк лимитов и tenant-контекст (как регистрирует // Собирает контекст: платный фейк + фейк лимитов и tenant-контекст (как регистрирует
// AddDealIntegrations при UseLocal=false). // AddDealIntegrations при UseLocal=false).
@@ -143,7 +145,7 @@ public sealed class BudgetedAiToolsTests
configure?.Invoke(limits); configure?.Invoke(limits);
ITenantContext tenantContext = new TenantContext(); ITenantContext tenantContext = new TenantContext();
tenantContext.SetTenant(new TenantId(TenantIdValue)); tenantContext.SetTenant(new TenantId(TenantIdValue));
var paid = new FakeAiTools(); var paid = TestAiTools.New();
IAiTools decorator = new BudgetedAiTools( IAiTools decorator = new BudgetedAiTools(
paid, paid,
limits, limits,
@@ -1,8 +1,11 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models; using Deal.Contracts.Integrations.Models;
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Models;
using Deal.Modules.Discovery.Application.Services; using Deal.Modules.Discovery.Application.Services;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
using NSubstitute;
namespace Deal.Tests.Unit.Contracts; namespace Deal.Tests.Unit.Contracts;
@@ -43,8 +46,8 @@ public sealed class DiscoveryEvaluatorTests
[Fact] [Fact]
public async Task EvaluateMessage_AiFits_ReturnsAiVerdict() public async Task EvaluateMessage_AiFits_ReturnsAiVerdict()
{ {
(DiscoveryEvaluator evaluator, _, FakeMlClient ml, FakeAiTools ai) = Create(aiEnabled: true, mlEnabled: false); (DiscoveryEvaluator evaluator, _, FakeMlClient ml, IAiTools ai) = Create(aiEnabled: true, mlEnabled: false);
ai.Fit = new AiEvaluateFitResultDto(Fit: true, "тема совпадает"); ai.ReturnsFit(new AiEvaluateFitResultDto(Fit: true, "тема совпадает"));
DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync( DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync(
TaskDto(keywords: ["заказ"]), "Нужен заказ на разработку CRM для агентства недвижимости", CancellationToken.None); TaskDto(keywords: ["заказ"]), "Нужен заказ на разработку CRM для агентства недвижимости", CancellationToken.None);
@@ -52,14 +55,14 @@ public sealed class DiscoveryEvaluatorTests
Assert.True(fit.Fit); Assert.True(fit.Fit);
Assert.Equal("тема совпадает", fit.Reason); Assert.Equal("тема совпадает", fit.Reason);
Assert.Equal("ai", fit.Source); Assert.Equal("ai", fit.Source);
Assert.Equal(1, ai.EvaluateFitCalls); await ai.Received(1).EvaluateFitAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>());
} }
[Fact] [Fact]
public async Task EvaluateMessage_AiFailure_FallsBackToHeuristic() public async Task EvaluateMessage_AiFailure_FallsBackToHeuristic()
{ {
(DiscoveryEvaluator evaluator, _, _, FakeAiTools ai) = Create(aiEnabled: true, mlEnabled: false); (DiscoveryEvaluator evaluator, _, _, IAiTools ai) = Create(aiEnabled: true, mlEnabled: false);
ai.Error = new InvalidOperationException("ai-service недоступен"); // как gRPC AiUnavailableException ai.ThrowsOnEvaluate(new InvalidOperationException("ai-service недоступен")); // как gRPC AiUnavailableException
DiscoveryTaskDto task = TaskDto(keywords: ["заказ"]); DiscoveryTaskDto task = TaskDto(keywords: ["заказ"]);
DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync( 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); => 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(); var settings = new FakeSettingsStore();
settings.Preload(SettingsKeys.AiEnabled, aiEnabled ? "true" : "false"); settings.Preload(SettingsKeys.AiEnabled, aiEnabled ? "true" : "false");
settings.Preload(SettingsKeys.MlEnabled, mlEnabled ? "true" : "false"); settings.Preload(SettingsKeys.MlEnabled, mlEnabled ? "true" : "false");
var ml = new FakeMlClient(); var ml = new FakeMlClient();
var ai = new FakeAiTools(); var ai = TestAiTools.New();
return (new DiscoveryEvaluator(settings, ml, ai), settings, ml, ai); return (new DiscoveryEvaluator(settings, ml, ai), settings, ml, ai);
} }
@@ -5,6 +5,7 @@ using Deal.Tests.Unit.Modules.Discovery;
using Deal.Modules.Discovery.Application.Services; using Deal.Modules.Discovery.Application.Services;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
using NSubstitute; using NSubstitute;
using Grpc.Core; using Grpc.Core;
@@ -432,7 +433,7 @@ public sealed class DiscoveryWorkerServiceTests
settings.Preload(SettingsKeys.MlEnabled, "false"); settings.Preload(SettingsKeys.MlEnabled, "false");
settings.Preload(SettingsKeys.AiEnabled, "false"); settings.Preload(SettingsKeys.AiEnabled, "false");
var ml = new FakeMlClient(); var ml = new FakeMlClient();
var ai = new FakeAiTools(); var ai = TestAiTools.New();
var log = new DiscoveryLogService(store); var log = new DiscoveryLogService(store);
var blacklist = new DiscoveryBlacklistService(store); var blacklist = new DiscoveryBlacklistService(store);
var candidates = new DiscoveryCandidatesService(store, log, blacklist); var candidates = new DiscoveryCandidatesService(store, log, blacklist);
@@ -1,47 +0,0 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
namespace Deal.Tests.Unit.Contracts;
/// <summary>
/// Фейковая реализация <see cref="IAiTools"/> для тестов Discovery-воркера/оценки.
/// </summary>
public sealed class FakeAiTools : IAiTools
{
/// <summary>
/// Ответ EvaluateFitAsync; null — вызов бросит <see cref="NotSupportedException"/>
/// </summary>
public AiEvaluateFitResultDto? Fit { get; set; }
/// <summary>
/// Явный сбой EvaluateFitAsync
/// </summary>
public Exception? Error { get; set; }
/// <summary>
/// Сколько раз вызван EvaluateFitAsync
/// </summary>
public int EvaluateFitCalls { get; private set; }
/// <inheritdoc />
public Task<AiEvaluateFitResultDto> EvaluateFitAsync(
string text,
string description,
IReadOnlyCollection<string> keywords,
CancellationToken ct)
{
EvaluateFitCalls++;
if (Error is not null)
{
throw Error;
}
return Fit is null
? throw new NotSupportedException()
: Task.FromResult(Fit);
}
/// <inheritdoc />
public Task<AiGenerateKeywordsResultDto> GenerateKeywordsAsync(string description, CancellationToken ct)
=> throw new NotSupportedException();
}
@@ -0,0 +1,51 @@
using Deal.Contracts.Integrations.Abstractions;
using Deal.Contracts.Integrations.Models;
using NSubstitute;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Подставка <see cref="IAiTools"/> для сценариев Discovery/бюджетного гейта.
/// </summary>
public static class TestAiTools
{
/// <summary>
/// Создаёт подставку: по умолчанию оба метода бросают <see cref="NotSupportedException"/> —
/// тесты переопределяют только то, что ждут (как раньше делал фейк).
/// </summary>
public static IAiTools New() => Configure(Substitute.For<IAiTools>());
/// <summary>
/// Настраивает «запрещённые по умолчанию» методы подставки.
/// </summary>
/// <param name="ai">Подставка инструментов ИИ.</param>
public static IAiTools Configure(IAiTools ai)
{
ai.EvaluateFitAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>())
.Returns<AiEvaluateFitResultDto>(_ => throw new NotSupportedException());
ai.GenerateKeywordsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns<AiGenerateKeywordsResultDto>(_ => throw new NotSupportedException());
return ai;
}
/// <summary>
/// Сценарий: <see cref="IAiTools.EvaluateFitAsync"/> возвращает заданный результат.
/// </summary>
/// <param name="ai">Подставка инструментов ИИ.</param>
/// <param name="fit">Результат оценки соответствия.</param>
public static void ReturnsFit(this IAiTools ai, AiEvaluateFitResultDto fit) =>
ai.EvaluateFitAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>())
.Returns(fit);
/// <summary>
/// Сценарий: <see cref="IAiTools.EvaluateFitAsync"/> бросает заданное исключение.
/// </summary>
/// <param name="ai">Подставка инструментов ИИ.</param>
/// <param name="error">Исключение для вызова.</param>
public static void ThrowsOnEvaluate(this IAiTools ai, Exception error) =>
ai.EvaluateFitAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>())
.Returns<AiEvaluateFitResultDto>(_ => throw error);
}