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

Хелпер Support/TestTenantRepository: фиксированный список + журнал запросов
страниц (PageRequests), неожиданные методы бросают NotSupportedException.
Потребители (7 файлов) перетипизированы на .Repository, фейк удалён,
тесты 1340 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-12 21:44:58 +03:00
parent 668c5b416d
commit 1bee0003ca
8 changed files with 89 additions and 40 deletions
@@ -90,7 +90,7 @@ public sealed class DiscoveryWorkerSchedulerTests
// Собирает планировщик на реальных сервисах модуля Discovery и тенант-фейках (без таймера).
private static Context CreateContext()
{
var tenants = new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB));
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
var tenantContext = new TenantContext();
var storeA = new FakeDiscoveryStore();
var storeB = new FakeDiscoveryStore();
@@ -103,7 +103,7 @@ public sealed class DiscoveryWorkerSchedulerTests
var services = new ServiceCollection();
services.AddSingleton<ITenantContext>(tenantContext);
services.AddSingleton<ITenantRepository>(tenants);
services.AddSingleton<ITenantRepository>(tenants.Repository);
services.AddSingleton<IMlClient>(new FakeMlClient());
services.AddSingleton<IAiTools>(_ => TestAiTools.New());
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
@@ -44,7 +44,7 @@ public sealed class MlOutboxFlushSchedulerTests
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new FakeTenantRepository(Tenant(TenantA)),
new TestTenantRepository(Tenant(TenantA)),
tenantContext,
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store });
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
@@ -70,7 +70,7 @@ public sealed class MlOutboxFlushSchedulerTests
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new FakeTenantRepository(Tenant(TenantA)),
new TestTenantRepository(Tenant(TenantA)),
tenantContext,
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store });
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
@@ -98,7 +98,7 @@ public sealed class MlOutboxFlushSchedulerTests
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)),
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)),
tenantContext,
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = storeA, [TenantB] = storeB });
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
@@ -124,7 +124,7 @@ public sealed class MlOutboxFlushSchedulerTests
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new FakeTenantRepository(Tenant(TenantA)),
new TestTenantRepository(Tenant(TenantA)),
tenantContext,
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store });
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
@@ -147,13 +147,13 @@ public sealed class MlOutboxFlushSchedulerTests
// Возвращает: Провайдер с зарегистрированными сервисами теста.
private static ServiceProvider BuildProvider(
int port,
ITenantRepository tenants,
TestTenantRepository tenants,
TenantContext tenantContext,
Dictionary<Guid, FakeMlLearningStore> storesByTenant)
{
var services = new ServiceCollection();
services.AddSingleton<ITenantContext>(tenantContext);
services.AddSingleton(tenants);
services.AddSingleton(tenants.Repository);
// Транспорт ml-service создаётся сразу (как в AddDealIntegrations при UseLocal=false): env-токен уже
// выставлен харнессом MlGrpcTestHost, endpoint — эфемерный порт хоста-фейка.
services.AddSingleton(new MlGrpcConnection(new MlServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" }));
@@ -1,4 +1,5 @@
using Deal.Api.Observability;
using Deal.Tests.Unit.Support;
using Deal.Contracts.Integrations.Abstractions;
using Deal.Infrastructure.Data;
using Deal.Modules.Kanban.Application.Abstractions;
@@ -145,12 +146,12 @@ public sealed class RuntimeDepthsCollectorTests
{
var services = new ServiceCollection();
services.AddSingleton<ITenantContext, TenantContext>();
services.AddSingleton<ITenantRepository>(new FakeTenantRepository(
services.AddSingleton<ITenantRepository>(new TestTenantRepository(
(tenants ?? new[]
{
new TenantRecordDto(TenantA, "A", "active", DateTimeOffset.UtcNow),
new TenantRecordDto(TenantB, "B", "active", DateTimeOffset.UtcNow),
}).ToArray()));
}).ToArray()).Repository);
services.AddScoped<IPipelineStore>(provider => pipelineByTenant[CurrentTenant(provider)]);
services.AddScoped<IMlClient>(_ => new FakeMlClient());
@@ -21,7 +21,7 @@ public sealed class TenantSchemaMigrationServiceTests
[Fact]
public async Task MigrateAllAsync_EmptyRegistry_ReturnsEmptySummary()
{
var service = NewService(new FakeTenantRepository(), new TestTenantProvisioner().Provisioner);
var service = NewService(new TestTenantRepository().Repository, new TestTenantProvisioner().Provisioner);
TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None);
@@ -35,10 +35,10 @@ public sealed class TenantSchemaMigrationServiceTests
public async Task MigrateAllAsync_ProvisionsEveryTenantSchema()
{
var provisioner = new TestTenantProvisioner();
var repository = new FakeTenantRepository(
var repository = new TestTenantRepository(
TenantRecord(TenantA, "A"),
TenantRecord(TenantB, "B"));
var service = NewService(repository, provisioner.Provisioner);
var service = NewService(repository.Repository, provisioner.Provisioner);
TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None);
@@ -56,10 +56,10 @@ public sealed class TenantSchemaMigrationServiceTests
{
string failingSchema = $"tenant_{TenantA:N}";
var provisioner = new TestTenantProvisioner(failingSchema);
var repository = new FakeTenantRepository(
var repository = new TestTenantRepository(
TenantRecord(TenantA, "A"),
TenantRecord(TenantB, "B"));
var service = NewService(repository, provisioner.Provisioner);
var service = NewService(repository.Repository, provisioner.Provisioner);
TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None);
@@ -76,8 +76,8 @@ public sealed class TenantSchemaMigrationServiceTests
public async Task MigrateAllAsync_MultiplePages_ReadsEveryTenant()
{
var provisioner = new TestTenantProvisioner();
var repository = new FakeTenantRepository(Records(5));
var service = NewService(repository, provisioner.Provisioner);
var repository = new TestTenantRepository(Records(5));
var service = NewService(repository.Repository, provisioner.Provisioner);
TenantMigrationSummary summary = await service.MigrateAllAsync(4, 2, CancellationToken.None);
@@ -97,8 +97,8 @@ public sealed class TenantSchemaMigrationServiceTests
public async Task MigrateAllAsync_PageSizeOne_ReadsEveryTenant()
{
var provisioner = new TestTenantProvisioner();
var repository = new FakeTenantRepository(Records(4));
var service = NewService(repository, provisioner.Provisioner);
var repository = new TestTenantRepository(Records(4));
var service = NewService(repository.Repository, provisioner.Provisioner);
TenantMigrationSummary summary = await service.MigrateAllAsync(2, 1, CancellationToken.None);
@@ -118,8 +118,8 @@ public sealed class TenantSchemaMigrationServiceTests
TenantRecordDto[] records = Records(3);
string failingSchema = $"tenant_{records[0].Id:N}";
var provisioner = new TestTenantProvisioner(failingSchema);
var repository = new FakeTenantRepository(records);
var service = NewService(repository, provisioner.Provisioner);
var repository = new TestTenantRepository(records);
var service = NewService(repository.Repository, provisioner.Provisioner);
TenantMigrationSummary summary = await service.MigrateAllAsync(1, 2, CancellationToken.None);
@@ -136,8 +136,8 @@ public sealed class TenantSchemaMigrationServiceTests
public async Task MigrateAllAsync_NonPositiveParameters_ClampToMinimum()
{
var provisioner = new TestTenantProvisioner();
var repository = new FakeTenantRepository(Records(3));
var service = NewService(repository, provisioner.Provisioner);
var repository = new TestTenantRepository(Records(3));
var service = NewService(repository.Repository, provisioner.Provisioner);
TenantMigrationSummary summary = await service.MigrateAllAsync(0, 0, CancellationToken.None);
@@ -152,10 +152,10 @@ public sealed class TenantSchemaMigrationServiceTests
public async Task MigrateAllAsync_WithExplicitParallelism_ProvisionsEveryTenant()
{
var provisioner = new TestTenantProvisioner();
var repository = new FakeTenantRepository(
var repository = new TestTenantRepository(
TenantRecord(TenantA, "A"),
TenantRecord(TenantB, "B"));
var service = NewService(repository, provisioner.Provisioner);
var service = NewService(repository.Repository, provisioner.Provisioner);
TenantMigrationSummary summary = await service.MigrateAllAsync(2, CancellationToken.None);
@@ -31,7 +31,7 @@ public sealed class BudgetAlertSchedulerTests
limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 800);
limits.Preload(TenantB, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 1000);
await using ServiceProvider provider = BuildProvider(
new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), limits);
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository, limits);
SseBroker broker = provider.GetRequiredService<SseBroker>();
SseSubscription subscriptionA = broker.Subscribe(TenantA);
@@ -56,7 +56,7 @@ public sealed class BudgetAlertSchedulerTests
{
FakeTenantLimitStore limits = new();
limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 700);
await using ServiceProvider provider = BuildProvider(new FakeTenantRepository(Tenant(TenantA)), limits);
await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits);
SseBroker broker = provider.GetRequiredService<SseBroker>();
SseSubscription subscriptionA = broker.Subscribe(TenantA);
@@ -76,7 +76,7 @@ public sealed class BudgetAlertSchedulerTests
// расход ≥ бюджета): за один проход выходят оба порога ровно по одному разу.
FakeTenantLimitStore limits = new();
limits.Preload(TenantA, budgetTokens: 500, TenantLimitPeriods.Month, Now(), usedTokens: 700);
await using ServiceProvider provider = BuildProvider(new FakeTenantRepository(Tenant(TenantA)), limits);
await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits);
SseBroker broker = provider.GetRequiredService<SseBroker>();
SseSubscription subscriptionA = broker.Subscribe(TenantA);
@@ -95,7 +95,7 @@ public sealed class BudgetAlertSchedulerTests
{
FakeTenantLimitStore limits = new();
limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 0);
await using ServiceProvider provider = BuildProvider(new FakeTenantRepository(Tenant(TenantA)), limits);
await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits);
SseBroker broker = provider.GetRequiredService<SseBroker>();
SseSubscription subscriptionA = broker.Subscribe(TenantA);
@@ -148,7 +148,7 @@ public sealed class PipelineWorkerSchedulerTests
// Возвращает: Планировщик, фейки очередей/канбана тенантов, каналы подписок и гейт.
private static Context CreateContext(bool withThrowingQueueReadA = false)
{
var tenants = new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB));
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
var tenantContext = new TenantContext();
FakePipelineStore pipelineA = withThrowingQueueReadA ? new ThrowingQueueReadPipelineStore() : new FakePipelineStore();
var pipelineB = new FakePipelineStore();
@@ -165,7 +165,7 @@ public sealed class PipelineWorkerSchedulerTests
var services = new ServiceCollection();
services.AddSingleton<ITenantContext>(tenantContext);
services.AddSingleton<SseBroker>();
services.AddSingleton<ITenantRepository>(tenants);
services.AddSingleton<ITenantRepository>(tenants.Repository);
services.AddSingleton(pumpGate);
services.AddSingleton<IMlClient>(new FakeMlClient());
services.AddSingleton<IAiClassifier>(aiClassifier.Classifier);
@@ -45,7 +45,7 @@ public sealed class StorageTickSchedulerTests
FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old");
FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var settings = new FakeSettingsStore();
var tenants = new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB));
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(tenants, tenantContext, storeA, storeB, settings);
@@ -76,7 +76,7 @@ public sealed class StorageTickSchedulerTests
var settings = new FakeSettingsStore();
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)),
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)),
tenantContext,
storeA,
storeB,
@@ -112,7 +112,7 @@ public sealed class StorageTickSchedulerTests
[TenantB] = storeB,
};
await using ServiceProvider provider = BuildProvider(
new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)),
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
tenantContext,
storesByTenant,
settingsByTenant);
@@ -158,7 +158,7 @@ public sealed class StorageTickSchedulerTests
pipelineStoreA.SeedRejected(Rejected("r_old_a", (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds)));
pipelineStoreA.SeedRejected(Rejected("r_fresh", (long)(nowMs - TimeSpan.FromDays(1).TotalMilliseconds)));
await using ServiceProvider provider = BuildProvider(
new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)),
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 },
@@ -188,7 +188,7 @@ public sealed class StorageTickSchedulerTests
var cardStoreB = new FakeKanjStore();
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)),
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() },
@@ -223,7 +223,7 @@ public sealed class StorageTickSchedulerTests
settingsA.Preload(SettingsKeys.RemindersEnabled, "false");
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new FakeTenantRepository(Tenant(TenantA)),
new TestTenantRepository(Tenant(TenantA)).Repository,
tenantContext,
new Dictionary<Guid, FakeKanjStore> { [TenantA] = cardStoreA },
new Dictionary<Guid, ISettingsStore> { [TenantA] = settingsA },
@@ -249,7 +249,7 @@ public sealed class StorageTickSchedulerTests
FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)),
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() },
@@ -279,14 +279,14 @@ public sealed class StorageTickSchedulerTests
// settings: Настройки обоих тенантов (пустые — дефолты тика).
// Возвращает: Провайдер с зарегистрированными сервисами теста.
private static ServiceProvider BuildProvider(
FakeTenantRepository tenants,
TestTenantRepository tenants,
TenantContext tenantContext,
FakeKanjStore storeA,
FakeKanjStore storeB,
FakeSettingsStore settings)
{
return BuildProvider(
tenants,
tenants.Repository,
tenantContext,
new Dictionary<Guid, FakeKanjStore> { [TenantA] = storeA, [TenantB] = storeB },
new Dictionary<Guid, ISettingsStore> { [TenantA] = settings, [TenantB] = settings });
@@ -0,0 +1,48 @@
using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Models;
using NSubstitute;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Подставка <see cref="ITenantRepository"/> с фиксированным списком тенантов: сервисы получают
/// NSubstitute-подставку (<see cref="Repository"/>), тесты проверяют запросы страниц через
/// <see cref="PageRequests"/>. Неожиданные методы бросают <see cref="NotSupportedException"/>.
/// </summary>
public sealed class TestTenantRepository
{
private readonly IReadOnlyList<TenantRecordDto> _tenants;
private readonly List<(int Offset, int Limit)> _pageRequests = [];
/// <summary>
/// Подставка порта реестра тенантов (создаётся в конструкторе).
/// </summary>
public ITenantRepository Repository { get; }
/// <summary>
/// Запрошенные страницы (offset, limit) в порядке обращений.
/// </summary>
public IReadOnlyList<(int Offset, int Limit)> PageRequests => _pageRequests;
/// <summary>
/// Создаёт реестр с фиксированным списком тенантов.
/// </summary>
/// <param name="tenants">Тенанты реестра (как строки public.tenants).</param>
public TestTenantRepository(params TenantRecordDto[] tenants)
{
_tenants = tenants.ToList();
Repository = Substitute.For<ITenantRepository>();
Repository.ListAsync(Arg.Any<CancellationToken>())
.Returns(_tenants);
Repository.When(r => r.ListPageAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>()))
.Do(ci => _pageRequests.Add((ci.ArgAt<int>(0), ci.ArgAt<int>(1))));
Repository.ListPageAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => (IReadOnlyList<TenantRecordDto>)_tenants.Skip(ci.ArgAt<int>(0)).Take(ci.ArgAt<int>(1)).ToList());
Repository.FindByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
.Returns<TenantRecordDto?>(_ => throw new NotSupportedException("FindByIdAsync не используется тестами реестра"));
Repository.UpdateStatusAsync(Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns<bool>(_ => throw new NotSupportedException("UpdateStatusAsync не используется тестами реестра"));
Repository.When(r => r.CreateAsync(Arg.Any<TenantRecordDto>(), Arg.Any<CancellationToken>()))
.Do(_ => throw new NotSupportedException("CreateAsync не используется тестами реестра"));
}
}