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

Хелпер Support/TestDiscoveryStore сгенерирован из фейка: состояние (tasks/
candidates/blacklist/log/monitored) сохранено, 29 методов порта читают состояние
на момент вызова, неожиданных методов нет. Потребители (8 файлов) перетипизированы
на .Store, фейк удалён, тесты 1340 зелёные.
This commit is contained in:
Rustam Khalimov
2026-09-12 22:06:52 +03:00
parent 435c473df4
commit f0cb807bff
8 changed files with 701 additions and 85 deletions
@@ -35,8 +35,8 @@ public sealed class DiscoveryWorkerSchedulerTests
// Контекст теста: планировщик на общих фейках + хранилища/гейты тенантов. // Контекст теста: планировщик на общих фейках + хранилища/гейты тенантов.
private sealed record Context( private sealed record Context(
DiscoveryWorkerScheduler Scheduler, DiscoveryWorkerScheduler Scheduler,
FakeDiscoveryStore StoreA, TestDiscoveryStore StoreA,
FakeDiscoveryStore StoreB, TestDiscoveryStore StoreB,
TestDiscoveryGateway GatewayA, TestDiscoveryGateway GatewayA,
TestDiscoveryGateway GatewayB, TestDiscoveryGateway GatewayB,
TenantContext TenantContext, TenantContext TenantContext,
@@ -92,8 +92,8 @@ public sealed class DiscoveryWorkerSchedulerTests
{ {
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)); var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
var tenantContext = new TenantContext(); var tenantContext = new TenantContext();
var storeA = new FakeDiscoveryStore(); var storeA = new TestDiscoveryStore();
var storeB = new FakeDiscoveryStore(); var storeB = new TestDiscoveryStore();
var settingsA = new FakeSettingsStore(); var settingsA = new FakeSettingsStore();
var settingsB = new FakeSettingsStore(); var settingsB = new FakeSettingsStore();
var gatewayA = new TestDiscoveryGateway(); var gatewayA = new TestDiscoveryGateway();
@@ -108,7 +108,7 @@ public sealed class DiscoveryWorkerSchedulerTests
services.AddSingleton<IAiTools>(_ => TestAiTools.New()); 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.Store : storeB.Store);
services.AddScoped<ISettingsStore>(provider => TenantOf(provider) == TenantA ? settingsA : settingsB); services.AddScoped<ISettingsStore>(provider => TenantOf(provider) == TenantA ? settingsA : settingsB);
services.AddScoped<ITelegramGateway>(provider => TenantOf(provider) == TenantA ? gatewayA.Gateway : gatewayB.Gateway); services.AddScoped<ITelegramGateway>(provider => TenantOf(provider) == TenantA ? gatewayA.Gateway : gatewayB.Gateway);
// Реальные сервисы модуля Discovery — как AddDiscoveryModule в Program.cs: цикл резолвит их в tenant-scope. // Реальные сервисы модуля Discovery — как AddDiscoveryModule в Program.cs: цикл резолвит их в tenant-scope.
@@ -418,7 +418,7 @@ public sealed class DiscoveryWorkerServiceTests
// Контекст теста: воркер на фейках + ссылки для проверок состояния. // Контекст теста: воркер на фейках + ссылки для проверок состояния.
private sealed record Fixture( private sealed record Fixture(
FakeDiscoveryStore Store, TestDiscoveryStore Store,
FakeSettingsStore Settings, FakeSettingsStore Settings,
TestDiscoveryGateway Gateway, TestDiscoveryGateway Gateway,
IDiscoveryPacer Pacer, IDiscoveryPacer Pacer,
@@ -428,22 +428,22 @@ public sealed class DiscoveryWorkerServiceTests
// Создаёт воркер на общих фейках; ML/ИИ выключены — оценка идёт эвристикой по ключам. // Создаёт воркер на общих фейках; ML/ИИ выключены — оценка идёт эвристикой по ключам.
private static Fixture Create() private static Fixture Create()
{ {
var store = new FakeDiscoveryStore(); var store = new TestDiscoveryStore();
var settings = new FakeSettingsStore(); var settings = new FakeSettingsStore();
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 = TestAiTools.New(); var ai = TestAiTools.New();
var log = new DiscoveryLogService(store); var log = new DiscoveryLogService(store.Store);
var blacklist = new DiscoveryBlacklistService(store); var blacklist = new DiscoveryBlacklistService(store.Store);
var candidates = new DiscoveryCandidatesService(store, log, blacklist); var candidates = new DiscoveryCandidatesService(store.Store, log, blacklist);
var planGuard = new DiscoveryPlanGuard(store, settings); var planGuard = new DiscoveryPlanGuard(store.Store, settings);
var tasks = new DiscoveryTasksService(store, planGuard, settings); var tasks = new DiscoveryTasksService(store.Store, planGuard, settings);
var banGuard = new DiscoveryBanGuard(store, settings); var banGuard = new DiscoveryBanGuard(store.Store, settings);
var pacer = Substitute.For<IDiscoveryPacer>(); var pacer = Substitute.For<IDiscoveryPacer>();
var gateway = new TestDiscoveryGateway(); var gateway = new TestDiscoveryGateway();
var worker = new DiscoveryWorkerService( var worker = new DiscoveryWorkerService(
store, tasks, candidates, log, new DiscoveryEvaluator(settings, ml, ai), banGuard, pacer, gateway.Gateway); store.Store, tasks, candidates, log, new DiscoveryEvaluator(settings, ml, ai), banGuard, pacer, gateway.Gateway);
return new Fixture(store, settings, gateway, pacer, worker, tasks); return new Fixture(store, settings, gateway, pacer, worker, tasks);
} }
@@ -1,4 +1,5 @@
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Models;
using Deal.Tests.Unit.Support;
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;
@@ -28,7 +29,7 @@ public sealed class DiscoveryBanGuardTests
[Fact] [Fact]
public async Task CanAutoJoin_LimitReachedToday_Blocked() public async Task CanAutoJoin_LimitReachedToday_Blocked()
{ {
(DiscoveryBanGuard guard, FakeDiscoveryStore store, _) = Create(); (DiscoveryBanGuard guard, TestDiscoveryStore store, _) = Create();
for (int i = 0; i < 50; i++) for (int i = 0; i < 50; i++)
{ {
@@ -38,9 +39,9 @@ public sealed class DiscoveryBanGuardTests
Assert.False(await guard.CanAutoJoinAsync(CancellationToken.None)); Assert.False(await guard.CanAutoJoinAsync(CancellationToken.None));
// Вчерашние вступления в суточную квоту не входят: 49 сегодня → ещё можно. // Вчерашние вступления в суточную квоту не входят: 49 сегодня → ещё можно.
FakeDiscoveryStore yesterdayStore = new(() => Now); TestDiscoveryStore yesterdayStore = new(() => Now);
var settings = new FakeSettingsStore(); var settings = new FakeSettingsStore();
var secondGuard = new DiscoveryBanGuard(yesterdayStore, settings, () => Now); var secondGuard = new DiscoveryBanGuard(yesterdayStore.Store, settings, () => Now);
for (int i = 0; i < 49; i++) for (int i = 0; i < 49; i++)
{ {
yesterdayStore.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddMinutes(i)); yesterdayStore.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddMinutes(i));
@@ -53,7 +54,7 @@ public sealed class DiscoveryBanGuardTests
[Fact] [Fact]
public async Task CountsOnlyJoinAutoEventsToday() public async Task CountsOnlyJoinAutoEventsToday()
{ {
(DiscoveryBanGuard guard, FakeDiscoveryStore store, _) = Create(); (DiscoveryBanGuard guard, TestDiscoveryStore store, _) = Create();
store.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddHours(1)); store.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddHours(1));
store.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddHours(2)); store.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddHours(2));
@@ -98,7 +99,7 @@ public sealed class DiscoveryBanGuardTests
[Fact] [Fact]
public async Task CustomJoinLimit_AppliedWhenSettingStored() public async Task CustomJoinLimit_AppliedWhenSettingStored()
{ {
(DiscoveryBanGuard guard, FakeDiscoveryStore store, FakeSettingsStore settings) = Create(); (DiscoveryBanGuard guard, TestDiscoveryStore store, FakeSettingsStore settings) = Create();
settings.Preload(SettingsKeys.DiscJoinLimit, "2"); settings.Preload(SettingsKeys.DiscJoinLimit, "2");
Assert.True(await guard.CanAutoJoinAsync(CancellationToken.None)); Assert.True(await guard.CanAutoJoinAsync(CancellationToken.None));
@@ -108,11 +109,11 @@ public sealed class DiscoveryBanGuardTests
} }
// Создаёт связку гард + фейки на фиксированных «часах» (store и гард смотрят одно время). // Создаёт связку гард + фейки на фиксированных «часах» (store и гард смотрят одно время).
private static (DiscoveryBanGuard Guard, FakeDiscoveryStore Store, FakeSettingsStore Settings) Create() private static (DiscoveryBanGuard Guard, TestDiscoveryStore Store, FakeSettingsStore Settings) Create()
{ {
var store = new FakeDiscoveryStore(() => Now); var store = new TestDiscoveryStore(() => Now);
var settings = new FakeSettingsStore(); var settings = new FakeSettingsStore();
var guard = new DiscoveryBanGuard(store, settings, () => Now); var guard = new DiscoveryBanGuard(store.Store, settings, () => Now);
return (guard, store, settings); return (guard, store, settings);
} }
} }
@@ -1,4 +1,5 @@
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Discovery.Application.Services; using Deal.Modules.Discovery.Application.Services;
namespace Deal.Tests.Unit.Modules.Discovery; namespace Deal.Tests.Unit.Modules.Discovery;
@@ -11,7 +12,7 @@ public sealed class DiscoveryBlacklistServiceTests
[Fact] [Fact]
public async Task Add_EmptyName_UsesDialogId() public async Task Add_EmptyName_UsesDialogId()
{ {
(DiscoveryBlacklistService service, FakeDiscoveryStore store) = Create(); (DiscoveryBlacklistService service, TestDiscoveryStore store) = Create();
DiscoveryBlacklistDto entry = await service.AddAsync("-1001", "", "отклонён", CancellationToken.None); DiscoveryBlacklistDto entry = await service.AddAsync("-1001", "", "отклонён", CancellationToken.None);
@@ -24,8 +25,8 @@ public sealed class DiscoveryBlacklistServiceTests
public async Task Add_OverwriteUpdatesNameAndReason_KeepsCreatedAt() public async Task Add_OverwriteUpdatesNameAndReason_KeepsCreatedAt()
{ {
long tick = 0; long tick = 0;
var store = new FakeDiscoveryStore(() => DateTimeOffset.FromUnixTimeMilliseconds(++tick * 1000L)); var store = new TestDiscoveryStore(() => DateTimeOffset.FromUnixTimeMilliseconds(++tick * 1000L));
var service = new DiscoveryBlacklistService(store); var service = new DiscoveryBlacklistService(store.Store);
// Первая запись источника — CreatedAt фиксируется (t=1000). // Первая запись источника — CreatedAt фиксируется (t=1000).
DiscoveryBlacklistDto first = await service.AddAsync("-1001", "Первое имя", "отклонено вручную", CancellationToken.None); DiscoveryBlacklistDto first = await service.AddAsync("-1001", "Первое имя", "отклонено вручную", CancellationToken.None);
@@ -41,7 +42,7 @@ public sealed class DiscoveryBlacklistServiceTests
[Fact] [Fact]
public async Task Remove_DeletesRow() public async Task Remove_DeletesRow()
{ {
(DiscoveryBlacklistService service, FakeDiscoveryStore store) = Create(); (DiscoveryBlacklistService service, TestDiscoveryStore store) = Create();
await service.AddAsync("-1001", "Канал", "причина", CancellationToken.None); await service.AddAsync("-1001", "Канал", "причина", CancellationToken.None);
await service.RemoveAsync("-1001", CancellationToken.None); await service.RemoveAsync("-1001", CancellationToken.None);
@@ -53,8 +54,8 @@ public sealed class DiscoveryBlacklistServiceTests
public async Task List_ReturnsNewestFirst() public async Task List_ReturnsNewestFirst()
{ {
long tick = 0; long tick = 0;
var store = new FakeDiscoveryStore(() => DateTimeOffset.FromUnixTimeMilliseconds(++tick * 1000L)); var store = new TestDiscoveryStore(() => DateTimeOffset.FromUnixTimeMilliseconds(++tick * 1000L));
var service = new DiscoveryBlacklistService(store); var service = new DiscoveryBlacklistService(store.Store);
await service.AddAsync("-1001", "Старый", "r1", CancellationToken.None); // t=1000 await service.AddAsync("-1001", "Старый", "r1", CancellationToken.None); // t=1000
await service.AddAsync("-1002", "Новый", "r2", CancellationToken.None); // t=2000 await service.AddAsync("-1002", "Новый", "r2", CancellationToken.None); // t=2000
@@ -65,9 +66,9 @@ public sealed class DiscoveryBlacklistServiceTests
} }
// Создаёт связку blacklist-сервиса + фейк. // Создаёт связку blacklist-сервиса + фейк.
private static (DiscoveryBlacklistService Service, FakeDiscoveryStore Store) Create() private static (DiscoveryBlacklistService Service, TestDiscoveryStore Store) Create()
{ {
var store = new FakeDiscoveryStore(); var store = new TestDiscoveryStore();
return (new DiscoveryBlacklistService(store), store); return (new DiscoveryBlacklistService(store.Store), store);
} }
} }
@@ -1,5 +1,6 @@
using Deal.Modules.Discovery.Application.Exceptions; using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Discovery.Application.Services; using Deal.Modules.Discovery.Application.Services;
namespace Deal.Tests.Unit.Modules.Discovery; namespace Deal.Tests.Unit.Modules.Discovery;
@@ -12,7 +13,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact] [Fact]
public async Task Add_NewSource_CreatesNewCandidateAndBumpsFound() public async Task Add_NewSource_CreatesNewCandidateAndBumpsFound()
{ {
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create(); (DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1")); store.SeedTask(Task("dt_1"));
DiscoveryCandidateDto? candidate = await service.AddAsync( DiscoveryCandidateDto? candidate = await service.AddAsync(
@@ -34,7 +35,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact] [Fact]
public async Task Add_EmptyNameAndKind_UsesDefaults() public async Task Add_EmptyNameAndKind_UsesDefaults()
{ {
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create(); (DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1")); store.SeedTask(Task("dt_1"));
DiscoveryCandidateDto? candidate = await service.AddAsync( DiscoveryCandidateDto? candidate = await service.AddAsync(
@@ -49,7 +50,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact] [Fact]
public async Task Add_AlreadyMonitored_SkipsWithLog() public async Task Add_AlreadyMonitored_SkipsWithLog()
{ {
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create(); (DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1")); store.SeedTask(Task("dt_1"));
store.SeedMonitored("-1001"); store.SeedMonitored("-1001");
@@ -67,9 +68,9 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact] [Fact]
public async Task Add_Blacklisted_SkipsWithLog() public async Task Add_Blacklisted_SkipsWithLog()
{ {
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create(); (DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1")); store.SeedTask(Task("dt_1"));
await store.UpsertBlacklistAsync("-1001", "Канал", "отклонён вручную", CancellationToken.None); await store.Store.UpsertBlacklistAsync("-1001", "Канал", "отклонён вручную", CancellationToken.None);
DiscoveryCandidateDto? candidate = await service.AddAsync( DiscoveryCandidateDto? candidate = await service.AddAsync(
"dt_1", "-1001", "Канал", "", DiscoveryCandidateKinds.Channel, "", CancellationToken.None); "dt_1", "-1001", "Канал", "", DiscoveryCandidateKinds.Channel, "", CancellationToken.None);
@@ -82,7 +83,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact] [Fact]
public async Task Add_AlreadyActiveCandidate_SkipsWithLog() public async Task Add_AlreadyActiveCandidate_SkipsWithLog()
{ {
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create(); (DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1")); store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1", status: DiscoveryCandidateStatuses.Review)); store.SeedCandidate(Candidate("-1001", "dt_1", status: DiscoveryCandidateStatuses.Review));
@@ -98,7 +99,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact] [Fact]
public async Task Add_StaleRejectedRow_IsReplacedByNewCandidate() public async Task Add_StaleRejectedRow_IsReplacedByNewCandidate()
{ {
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create(); (DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1")); store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1", status: DiscoveryCandidateStatuses.Rejected)); store.SeedCandidate(Candidate("-1001", "dt_1", status: DiscoveryCandidateStatuses.Rejected));
@@ -115,7 +116,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact] [Fact]
public async Task Add_MissingTask_ReturnsNullWithoutLog() public async Task Add_MissingTask_ReturnsNullWithoutLog()
{ {
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create(); (DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
DiscoveryCandidateDto? candidate = await service.AddAsync( DiscoveryCandidateDto? candidate = await service.AddAsync(
"dt_missing", "-1001", "Канал", "", DiscoveryCandidateKinds.Channel, "", CancellationToken.None); "dt_missing", "-1001", "Канал", "", DiscoveryCandidateKinds.Channel, "", CancellationToken.None);
@@ -128,7 +129,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact] [Fact]
public async Task MarkJoined_Auto_SetsJoinedCountersAndLog() public async Task MarkJoined_Auto_SetsJoinedCountersAndLog()
{ {
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create(); (DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1")); store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1")); store.SeedCandidate(Candidate("-1001", "dt_1"));
@@ -146,7 +147,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact] [Fact]
public async Task MarkJoined_Manual_LogsJoinManual() public async Task MarkJoined_Manual_LogsJoinManual()
{ {
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create(); (DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1")); store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1")); store.SeedCandidate(Candidate("-1001", "dt_1"));
@@ -161,7 +162,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact] [Fact]
public async Task MarkJoined_AlreadyJoined_IsIdempotent() public async Task MarkJoined_AlreadyJoined_IsIdempotent()
{ {
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create(); (DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1")); store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1", status: DiscoveryCandidateStatuses.Joined)); store.SeedCandidate(Candidate("-1001", "dt_1", status: DiscoveryCandidateStatuses.Joined));
@@ -186,7 +187,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact] [Fact]
public async Task MarkRejected_WritesRejectedCountersLogAndBlacklist() public async Task MarkRejected_WritesRejectedCountersLogAndBlacklist()
{ {
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create(); (DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1")); store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1", name: "Канал")); store.SeedCandidate(Candidate("-1001", "dt_1", name: "Канал"));
@@ -208,7 +209,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact] [Fact]
public async Task MarkRejected_AlreadyRejected_IsIdempotent() public async Task MarkRejected_AlreadyRejected_IsIdempotent()
{ {
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create(); (DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1")); store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1", status: DiscoveryCandidateStatuses.Rejected)); store.SeedCandidate(Candidate("-1001", "dt_1", status: DiscoveryCandidateStatuses.Rejected));
@@ -224,7 +225,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact] [Fact]
public async Task MarkRejected_JoinedCandidate_Throws() public async Task MarkRejected_JoinedCandidate_Throws()
{ {
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create(); (DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1")); store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1", status: DiscoveryCandidateStatuses.Joined)); store.SeedCandidate(Candidate("-1001", "dt_1", status: DiscoveryCandidateStatuses.Joined));
@@ -250,7 +251,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact] [Fact]
public async Task SetStatus_ToReview_WritesReviewLog() public async Task SetStatus_ToReview_WritesReviewLog()
{ {
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create(); (DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1")); store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1")); store.SeedCandidate(Candidate("-1001", "dt_1"));
@@ -267,7 +268,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact] [Fact]
public async Task SetStatus_Joined_Throws() public async Task SetStatus_Joined_Throws()
{ {
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create(); (DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1")); store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1")); store.SeedCandidate(Candidate("-1001", "dt_1"));
@@ -282,7 +283,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact] [Fact]
public async Task Set_UpdatesEvaluationFieldsAndKeepsEmptyName() public async Task Set_UpdatesEvaluationFieldsAndKeepsEmptyName()
{ {
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create(); (DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1")); store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1", name: "Канал")); store.SeedCandidate(Candidate("-1001", "dt_1", name: "Канал"));
@@ -316,7 +317,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact] [Fact]
public async Task Set_WrongTask_ReturnsNull() public async Task Set_WrongTask_ReturnsNull()
{ {
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create(); (DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1")); store.SeedTask(Task("dt_1"));
store.SeedTask(Task("dt_2")); store.SeedTask(Task("dt_2"));
store.SeedCandidate(Candidate("-1001", "dt_1")); store.SeedCandidate(Candidate("-1001", "dt_1"));
@@ -330,7 +331,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact] [Fact]
public async Task Delete_RemovesCandidateRow() public async Task Delete_RemovesCandidateRow()
{ {
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create(); (DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedCandidate(Candidate("-1001", "dt_1")); store.SeedCandidate(Candidate("-1001", "dt_1"));
await service.DeleteAsync("-1001", CancellationToken.None); await service.DeleteAsync("-1001", CancellationToken.None);
@@ -339,12 +340,12 @@ public sealed class DiscoveryCandidatesServiceTests
} }
// Создаёт связку кандидатного сервиса + фейк (лог/чёрный список — те же фейки). // Создаёт связку кандидатного сервиса + фейк (лог/чёрный список — те же фейки).
private static (DiscoveryCandidatesService Service, FakeDiscoveryStore Store) Create() private static (DiscoveryCandidatesService Service, TestDiscoveryStore Store) Create()
{ {
var store = new FakeDiscoveryStore(); var store = new TestDiscoveryStore();
var log = new DiscoveryLogService(store); var log = new DiscoveryLogService(store.Store);
var blacklist = new DiscoveryBlacklistService(store); var blacklist = new DiscoveryBlacklistService(store.Store);
return (new DiscoveryCandidatesService(store, log, blacklist), store); return (new DiscoveryCandidatesService(store.Store, log, blacklist), store);
} }
// Задача как строка DiscTasks (running; счётчики 0). // Задача как строка DiscTasks (running; счётчики 0).
@@ -1,4 +1,5 @@
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Discovery.Application.Services; using Deal.Modules.Discovery.Application.Services;
namespace Deal.Tests.Unit.Modules.Discovery; namespace Deal.Tests.Unit.Modules.Discovery;
@@ -11,8 +12,8 @@ public sealed class DiscoveryLogServiceTests
[Fact] [Fact]
public async Task Add_WritesRowWithDlPrefix() public async Task Add_WritesRowWithDlPrefix()
{ {
var store = new FakeDiscoveryStore(); var store = new TestDiscoveryStore();
var service = new DiscoveryLogService(store); var service = new DiscoveryLogService(store.Store);
await service.AddAsync("dt_1", DiscoveryLogEvents.Skip, "пропущен -1001: причина", CancellationToken.None); await service.AddAsync("dt_1", DiscoveryLogEvents.Skip, "пропущен -1001: причина", CancellationToken.None);
@@ -28,8 +29,8 @@ public sealed class DiscoveryLogServiceTests
public async Task TaskLog_ReturnsNewestFirst() public async Task TaskLog_ReturnsNewestFirst()
{ {
long tick = 0; long tick = 0;
var store = new FakeDiscoveryStore(() => DateTimeOffset.FromUnixTimeMilliseconds(++tick * 1000L)); var store = new TestDiscoveryStore(() => DateTimeOffset.FromUnixTimeMilliseconds(++tick * 1000L));
var service = new DiscoveryLogService(store); var service = new DiscoveryLogService(store.Store);
await service.AddAsync("dt_1", DiscoveryLogEvents.Search, "первое", CancellationToken.None); // t=1000 await service.AddAsync("dt_1", DiscoveryLogEvents.Search, "первое", CancellationToken.None); // t=1000
await service.AddAsync("dt_1", DiscoveryLogEvents.JoinAuto, "второе", CancellationToken.None); // t=2000 await service.AddAsync("dt_1", DiscoveryLogEvents.JoinAuto, "второе", CancellationToken.None); // t=2000
@@ -44,8 +45,8 @@ public sealed class DiscoveryLogServiceTests
[Fact] [Fact]
public async Task TaskLog_EmptyTask_ReturnsEmpty() public async Task TaskLog_EmptyTask_ReturnsEmpty()
{ {
var store = new FakeDiscoveryStore(); var store = new TestDiscoveryStore();
var service = new DiscoveryLogService(store); var service = new DiscoveryLogService(store.Store);
IReadOnlyList<DiscoveryLogDto> items = await service.TaskLogAsync("dt_no_log", CancellationToken.None); IReadOnlyList<DiscoveryLogDto> items = await service.TaskLogAsync("dt_no_log", CancellationToken.None);
@@ -56,8 +57,8 @@ public sealed class DiscoveryLogServiceTests
public async Task TaskLog_LimitTakesNewest() public async Task TaskLog_LimitTakesNewest()
{ {
long tick = 0; long tick = 0;
var store = new FakeDiscoveryStore(() => DateTimeOffset.FromUnixTimeMilliseconds(++tick * 1000L)); var store = new TestDiscoveryStore(() => DateTimeOffset.FromUnixTimeMilliseconds(++tick * 1000L));
var service = new DiscoveryLogService(store); var service = new DiscoveryLogService(store.Store);
await service.AddAsync("dt_1", DiscoveryLogEvents.Search, "первое", CancellationToken.None); await service.AddAsync("dt_1", DiscoveryLogEvents.Search, "первое", CancellationToken.None);
await service.AddAsync("dt_1", DiscoveryLogEvents.Search, "второе", CancellationToken.None); await service.AddAsync("dt_1", DiscoveryLogEvents.Search, "второе", CancellationToken.None);
@@ -1,5 +1,6 @@
using Deal.Modules.Discovery.Application.Exceptions; using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Discovery.Application.Services; using Deal.Modules.Discovery.Application.Services;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
@@ -13,7 +14,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact] [Fact]
public async Task Create_EmptyName_ThrowsAndCreatesNothing() public async Task Create_EmptyName_ThrowsAndCreatesNothing()
{ {
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); (DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
DiscoveryValidationException error = await Assert.ThrowsAsync<DiscoveryValidationException>( DiscoveryValidationException error = await Assert.ThrowsAsync<DiscoveryValidationException>(
() => service.CreateAsync(new DiscoveryTaskDraft { Name = " " }, CancellationToken.None)); () => service.CreateAsync(new DiscoveryTaskDraft { Name = " " }, CancellationToken.None));
@@ -25,7 +26,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact] [Fact]
public async Task Create_PlanBelowOne_Throws() public async Task Create_PlanBelowOne_Throws()
{ {
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); (DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
DiscoveryValidationException error = await Assert.ThrowsAsync<DiscoveryValidationException>( DiscoveryValidationException error = await Assert.ThrowsAsync<DiscoveryValidationException>(
() => service.CreateAsync(NewDraft(planJoins: 0), CancellationToken.None)); () => service.CreateAsync(NewDraft(planJoins: 0), CancellationToken.None));
@@ -37,7 +38,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact] [Fact]
public async Task Create_PlanAboveDailyLimit_Throws() public async Task Create_PlanAboveDailyLimit_Throws()
{ {
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); (DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
DiscoveryValidationException error = await Assert.ThrowsAsync<DiscoveryValidationException>( DiscoveryValidationException error = await Assert.ThrowsAsync<DiscoveryValidationException>(
() => service.CreateAsync(NewDraft(planJoins: 51), CancellationToken.None)); () => service.CreateAsync(NewDraft(planJoins: 51), CancellationToken.None));
@@ -49,7 +50,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact] [Fact]
public async Task Create_TaskAtLimitLeavesNoBudget_Throws() public async Task Create_TaskAtLimitLeavesNoBudget_Throws()
{ {
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); (DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1", planJoins: 50)); store.SeedTask(Task("dt_1", planJoins: 50));
DiscoveryValidationException error = await Assert.ThrowsAsync<DiscoveryValidationException>( DiscoveryValidationException error = await Assert.ThrowsAsync<DiscoveryValidationException>(
@@ -62,7 +63,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact] [Fact]
public async Task Create_RemainingQuota_FillsUpToLimitAndRejectsOver() public async Task Create_RemainingQuota_FillsUpToLimitAndRejectsOver()
{ {
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); (DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1", planJoins: 25)); store.SeedTask(Task("dt_1", planJoins: 25));
// 25 + 25 = 50 — вторая задача на 25 влезает (сумма планов ≤ discJoinLimit). // 25 + 25 = 50 — вторая задача на 25 влезает (сумма планов ≤ discJoinLimit).
@@ -79,7 +80,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact] [Fact]
public async Task Create_NormalizesAndCreatesDraft() public async Task Create_NormalizesAndCreatesDraft()
{ {
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); (DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
DiscoveryTaskDto task = await service.CreateAsync( DiscoveryTaskDto task = await service.CreateAsync(
new DiscoveryTaskDraft new DiscoveryTaskDraft
@@ -116,7 +117,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact] [Fact]
public async Task Create_CustomThresholdAndSample_Respected() public async Task Create_CustomThresholdAndSample_Respected()
{ {
(DiscoveryTasksService service, FakeDiscoveryStore store, FakeSettingsStore settings) = Create(); (DiscoveryTasksService service, TestDiscoveryStore store, FakeSettingsStore settings) = Create();
settings.Preload("discEvalThreshold", "60"); settings.Preload("discEvalThreshold", "60");
settings.Preload("discEvalSample", "20"); settings.Preload("discEvalSample", "20");
@@ -143,7 +144,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact] [Fact]
public async Task Patch_AppliesFieldsAndClamps() public async Task Patch_AppliesFieldsAndClamps()
{ {
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); (DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1", keywords: new[] { "старое" })); store.SeedTask(Task("dt_1", keywords: new[] { "старое" }));
DiscoveryTaskDto? task = await service.PatchAsync( DiscoveryTaskDto? task = await service.PatchAsync(
@@ -170,7 +171,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact] [Fact]
public async Task Patch_NullOnlyPatch_ReturnsCurrentWithoutWrite() public async Task Patch_NullOnlyPatch_ReturnsCurrentWithoutWrite()
{ {
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); (DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1", name: "Старое")); store.SeedTask(Task("dt_1", name: "Старое"));
DiscoveryTaskDto? task = await service.PatchAsync("dt_1", new DiscoveryTaskPatch(), CancellationToken.None); DiscoveryTaskDto? task = await service.PatchAsync("dt_1", new DiscoveryTaskPatch(), CancellationToken.None);
@@ -183,7 +184,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact] [Fact]
public async Task Patch_PlanGrowthBeyondBudget_Throws() public async Task Patch_PlanGrowthBeyondBudget_Throws()
{ {
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); (DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1", planJoins: 20)); store.SeedTask(Task("dt_1", planJoins: 20));
store.SeedTask(Task("dt_2", planJoins: 20)); store.SeedTask(Task("dt_2", planJoins: 20));
@@ -212,7 +213,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact] [Fact]
public async Task Start_NoKeywords_Throws() public async Task Start_NoKeywords_Throws()
{ {
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); (DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1", keywords: Array.Empty<string>())); store.SeedTask(Task("dt_1", keywords: Array.Empty<string>()));
DiscoveryValidationException error = await Assert.ThrowsAsync<DiscoveryValidationException>( DiscoveryValidationException error = await Assert.ThrowsAsync<DiscoveryValidationException>(
@@ -224,7 +225,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact] [Fact]
public async Task Start_FromPaused_KeepsSearchProgress() public async Task Start_FromPaused_KeepsSearchProgress()
{ {
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); (DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1", status: DiscoveryTaskStatuses.Paused, keywords: new[] { "a", "b" }, searchIdx: 1)); store.SeedTask(Task("dt_1", status: DiscoveryTaskStatuses.Paused, keywords: new[] { "a", "b" }, searchIdx: 1));
DiscoveryTaskDto? task = await service.StartAsync("dt_1", CancellationToken.None); DiscoveryTaskDto? task = await service.StartAsync("dt_1", CancellationToken.None);
@@ -238,7 +239,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact] [Fact]
public async Task Start_FromDone_ResetsSearchProgress() public async Task Start_FromDone_ResetsSearchProgress()
{ {
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); (DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
store.SeedTask(Task( store.SeedTask(Task(
"dt_1", "dt_1",
status: DiscoveryTaskStatuses.Done, status: DiscoveryTaskStatuses.Done,
@@ -261,7 +262,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact] [Fact]
public async Task Pause_SetsPaused() public async Task Pause_SetsPaused()
{ {
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); (DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1", status: DiscoveryTaskStatuses.Running, keywords: new[] { "a" })); store.SeedTask(Task("dt_1", status: DiscoveryTaskStatuses.Running, keywords: new[] { "a" }));
DiscoveryTaskDto? task = await service.PauseAsync("dt_1", CancellationToken.None); DiscoveryTaskDto? task = await service.PauseAsync("dt_1", CancellationToken.None);
@@ -273,12 +274,12 @@ public sealed class DiscoveryTasksServiceTests
[Fact] [Fact]
public async Task Delete_RemovesTaskCandidatesAndLog_KeepsBlacklist() public async Task Delete_RemovesTaskCandidatesAndLog_KeepsBlacklist()
{ {
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); (DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1")); store.SeedTask(Task("dt_1"));
store.SeedTask(Task("dt_2")); store.SeedTask(Task("dt_2"));
store.SeedCandidate(Candidate("c_1", "dt_1")); store.SeedCandidate(Candidate("c_1", "dt_1"));
store.SeedCandidate(Candidate("c_2", "dt_2")); store.SeedCandidate(Candidate("c_2", "dt_2"));
await store.UpsertBlacklistAsync("c_1", "Источник", "причина", CancellationToken.None); await store.Store.UpsertBlacklistAsync("c_1", "Источник", "причина", CancellationToken.None);
bool deleted = await service.DeleteAsync("dt_1", CancellationToken.None); bool deleted = await service.DeleteAsync("dt_1", CancellationToken.None);
@@ -303,7 +304,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact] [Fact]
public async Task AdvanceSearch_MovesIndexAndFinishes() public async Task AdvanceSearch_MovesIndexAndFinishes()
{ {
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); (DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1", keywords: new[] { "a", "b" })); store.SeedTask(Task("dt_1", keywords: new[] { "a", "b" }));
Assert.True(await service.AdvanceSearchAsync("dt_1", CancellationToken.None)); Assert.True(await service.AdvanceSearchAsync("dt_1", CancellationToken.None));
@@ -320,7 +321,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact] [Fact]
public async Task BumpCounter_IncrementsField() public async Task BumpCounter_IncrementsField()
{ {
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create(); (DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1")); store.SeedTask(Task("dt_1"));
Assert.True(await service.BumpCounterAsync("dt_1", DiscoveryCounterField.Evaluated, 3, CancellationToken.None)); Assert.True(await service.BumpCounterAsync("dt_1", DiscoveryCounterField.Evaluated, 3, CancellationToken.None));
@@ -329,12 +330,12 @@ public sealed class DiscoveryTasksServiceTests
} }
// Создаёт связку сервис + фейки (пустые настройки → дефолты SettingsDefaults). // Создаёт связку сервис + фейки (пустые настройки → дефолты SettingsDefaults).
private static (DiscoveryTasksService Service, FakeDiscoveryStore Store, FakeSettingsStore Settings) Create() private static (DiscoveryTasksService Service, TestDiscoveryStore Store, FakeSettingsStore Settings) Create()
{ {
var store = new FakeDiscoveryStore(); var store = new TestDiscoveryStore();
var settings = new FakeSettingsStore(); var settings = new FakeSettingsStore();
var planGuard = new DiscoveryPlanGuard(store, settings); var planGuard = new DiscoveryPlanGuard(store.Store, settings);
return (new DiscoveryTasksService(store, planGuard, settings), store, settings); return (new DiscoveryTasksService(store.Store, planGuard, settings), store, settings);
} }
// Draft задачи с дефолтными полями (name/planJoins переопределяются). // Draft задачи с дефолтными полями (name/planJoins переопределяются).
@@ -0,0 +1,611 @@
using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Models;
using NSubstitute;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Подставка <see cref="IDiscoveryStore"/> на списках: сервисы получают NSubstitute-подставку
/// (<see cref="Store"/>), тесты сеют данные и проверяют состояние напрямую.
/// </summary>
public sealed class TestDiscoveryStore
{
/// <summary>
/// Подставка порта Discovery-хранилища (создаётся в конструкторе).
/// </summary>
public IDiscoveryStore Store { get; }
private readonly List<DiscoveryTaskDto> _tasks = [];
private readonly List<DiscoveryCandidateDto> _candidates = [];
private readonly List<DiscoveryBlacklistDto> _blacklist = [];
private readonly List<DiscoveryLogDto> _log = [];
private readonly HashSet<string> _monitoredIds = new(StringComparer.Ordinal);
private readonly Func<DateTimeOffset> _clock;
/// <summary>
/// Создаёт подставку с «часами» по умолчанию
/// </summary>
/// <param name="clock">Источник текущего времени (по умолчанию <see cref="DateTimeOffset.UtcNow"/>).</param>
public TestDiscoveryStore(Func<DateTimeOffset>? clock = null)
{
Store = Substitute.For<IDiscoveryStore>();
_clock = clock ?? (() => DateTimeOffset.UtcNow);
Store.ListTasksAsync(Arg.Any<CancellationToken>())
.Returns(ci => ListTasksAsync(ci.ArgAt<CancellationToken>(0)));
Store.GetTaskAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => GetTaskAsync(ci.ArgAt<string>(0), ci.ArgAt<CancellationToken>(1)));
Store.CreateTaskAsync(Arg.Any<DiscoveryTaskRow>(), Arg.Any<CancellationToken>())
.Returns(ci => CreateTaskAsync(ci.ArgAt<DiscoveryTaskRow>(0), ci.ArgAt<CancellationToken>(1)));
Store.PatchTaskAsync(Arg.Any<string>(), Arg.Any<DiscoveryTaskPatch>(), Arg.Any<CancellationToken>())
.Returns(ci => PatchTaskAsync(ci.ArgAt<string>(0), ci.ArgAt<DiscoveryTaskPatch>(1), ci.ArgAt<CancellationToken>(2)));
Store.DeleteTaskAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => DeleteTaskAsync(ci.ArgAt<string>(0), ci.ArgAt<CancellationToken>(1)));
Store.SetTaskRunningAsync(Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(ci => SetTaskRunningAsync(ci.ArgAt<string>(0), ci.ArgAt<bool>(1), ci.ArgAt<CancellationToken>(2)));
Store.SetTaskPausedAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => SetTaskPausedAsync(ci.ArgAt<string>(0), ci.ArgAt<CancellationToken>(1)));
Store.SetTaskDoneAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => SetTaskDoneAsync(ci.ArgAt<string>(0), ci.ArgAt<CancellationToken>(1)));
Store.BumpTaskCounterAsync(Arg.Any<string>(), Arg.Any<DiscoveryCounterField>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => BumpTaskCounterAsync(ci.ArgAt<string>(0), ci.ArgAt<DiscoveryCounterField>(1), ci.ArgAt<int>(2), ci.ArgAt<CancellationToken>(3)));
Store.AdvanceSearchAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(ci => AdvanceSearchAsync(ci.ArgAt<string>(0), ci.ArgAt<int>(1), ci.ArgAt<bool>(2), ci.ArgAt<CancellationToken>(3)));
Store.SumActivePlanAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => SumActivePlanAsync(ci.ArgAt<string>(0), ci.ArgAt<CancellationToken>(1)));
Store.ListCandidatesAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => ListCandidatesAsync(ci.ArgAt<string>(0), ci.ArgAt<string>(1), ci.ArgAt<CancellationToken>(2)));
Store.GetCandidateAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => GetCandidateAsync(ci.ArgAt<string>(0), ci.ArgAt<CancellationToken>(1)));
Store.IsDialogMonitoredAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => IsDialogMonitoredAsync(ci.ArgAt<string>(0), ci.ArgAt<CancellationToken>(1)));
Store.IsBlacklistedAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => IsBlacklistedAsync(ci.ArgAt<string>(0), ci.ArgAt<CancellationToken>(1)));
Store.CreateCandidateAsync(Arg.Any<DiscoveryCandidateRow>(), Arg.Any<CancellationToken>())
.Returns(ci => CreateCandidateAsync(ci.ArgAt<DiscoveryCandidateRow>(0), ci.ArgAt<CancellationToken>(1)));
Store.DeleteCandidateAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => DeleteCandidateAsync(ci.ArgAt<string>(0), ci.ArgAt<CancellationToken>(1)));
Store.PatchCandidateAsync(Arg.Any<string>(), Arg.Any<DiscoveryCandidatePatch>(), Arg.Any<CancellationToken>())
.Returns(ci => PatchCandidateAsync(ci.ArgAt<string>(0), ci.ArgAt<DiscoveryCandidatePatch>(1), ci.ArgAt<CancellationToken>(2)));
Store.SetCandidateStatusAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => SetCandidateStatusAsync(ci.ArgAt<string>(0), ci.ArgAt<string>(1), ci.ArgAt<CancellationToken>(2)));
Store.SetCandidateJoinedAsync(Arg.Any<string>(), Arg.Any<bool>(), Arg.Any<CancellationToken>())
.Returns(ci => SetCandidateJoinedAsync(ci.ArgAt<string>(0), ci.ArgAt<bool>(1), ci.ArgAt<CancellationToken>(2)));
Store.IncrementJoinFailuresAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => IncrementJoinFailuresAsync(ci.ArgAt<string>(0), ci.ArgAt<CancellationToken>(1)));
Store.SetCandidateRejectedAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => SetCandidateRejectedAsync(ci.ArgAt<string>(0), ci.ArgAt<CancellationToken>(1)));
Store.UpsertBlacklistAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => UpsertBlacklistAsync(ci.ArgAt<string>(0), ci.ArgAt<string>(1), ci.ArgAt<string>(2), ci.ArgAt<CancellationToken>(3)));
Store.RemoveBlacklistAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => RemoveBlacklistAsync(ci.ArgAt<string>(0), ci.ArgAt<CancellationToken>(1)));
Store.GetBlacklistAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => GetBlacklistAsync(ci.ArgAt<string>(0), ci.ArgAt<CancellationToken>(1)));
Store.ListBlacklistAsync(Arg.Any<CancellationToken>())
.Returns(ci => ListBlacklistAsync(ci.ArgAt<CancellationToken>(0)));
Store.AddLogAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => AddLogAsync(ci.ArgAt<string>(0), ci.ArgAt<string>(1), ci.ArgAt<string>(2), ci.ArgAt<string>(3), ci.ArgAt<CancellationToken>(4)));
Store.CountLogEventAsync(Arg.Any<string>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(ci => CountLogEventAsync(ci.ArgAt<string>(0), ci.ArgAt<DateTimeOffset>(1), ci.ArgAt<CancellationToken>(2)));
Store.ListTaskLogAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => ListTaskLogAsync(ci.ArgAt<string>(0), ci.ArgAt<int>(1), ci.ArgAt<CancellationToken>(2)));
}
/// <summary>
/// Задачи фейка
/// </summary>
public IReadOnlyList<DiscoveryTaskDto> Tasks => _tasks.ToList();
/// <summary>
/// Кандидаты фейка
/// </summary>
public IReadOnlyList<DiscoveryCandidateDto> Candidates => _candidates.ToList();
/// <summary>
/// Чёрный список фейка
/// </summary>
public IReadOnlyList<DiscoveryBlacklistDto> Blacklist => _blacklist.ToList();
/// <summary>
/// Лог фейка в порядке записи
/// </summary>
public IReadOnlyList<DiscoveryLogDto> Log => _log.ToList();
/// <summary>
/// Кладёт задачу напрямую
/// </summary>
/// <param name="task">Задача как если бы была прочитана адаптером.</param>
public void SeedTask(DiscoveryTaskDto task)
{
_tasks.Add(task);
}
/// <summary>
/// Кладёт кандидата напрямую
/// </summary>
/// <param name="candidate">Кандидат как если бы был прочитан адаптером.</param>
public void SeedCandidate(DiscoveryCandidateDto candidate)
{
_candidates.Add(candidate);
}
/// <summary>
/// Помечает источник «уже мониторится» — есть в каталоге Dialogs.
/// </summary>
/// <param name="dialogId">Подписанный id источника.</param>
public void SeedMonitored(string dialogId)
{
_monitoredIds.Add(dialogId);
}
/// <summary>
/// Кладёт событие лога напрямую
/// </summary>
/// <param name="event">Событие (join_auto/…).</param>
/// <param name="createdAt">Момент события (UTC).</param>
/// <param name="taskId">Id задачи (по умолчанию — произвольный).</param>
public void SeedLog(
string @event,
DateTimeOffset createdAt,
string taskId = "dt_x")
{
_log.Add(new DiscoveryLogDto("dl_x", taskId, @event, string.Empty, createdAt.ToUnixTimeMilliseconds()));
}
private Task<IReadOnlyList<DiscoveryTaskDto>> ListTasksAsync(CancellationToken ct)
{
return Task.FromResult<IReadOnlyList<DiscoveryTaskDto>>(_tasks
.OrderBy(task => task.CreatedAt)
.ToList());
}
private Task<DiscoveryTaskDto?> GetTaskAsync(string taskId, CancellationToken ct)
{
return Task.FromResult(_tasks.FirstOrDefault(task => task.Id == taskId));
}
private Task CreateTaskAsync(DiscoveryTaskRow row, CancellationToken ct)
{
long nowMs = _clock().ToUnixTimeMilliseconds();
_tasks.Add(new DiscoveryTaskDto(
row.Id,
row.Name,
row.Description,
row.Keywords.ToList(),
row.MinSubscribers,
row.Lang,
row.Threshold,
row.SampleSize,
row.PlanJoins,
row.AutoJoin,
DiscoveryTaskStatuses.Draft,
SearchIdx: 0,
SearchDone: false,
Found: 0,
Evaluated: 0,
Joined: 0,
Rejected: 0,
nowMs,
nowMs));
return Task.CompletedTask;
}
private Task<bool> PatchTaskAsync( string taskId, DiscoveryTaskPatch patch, CancellationToken ct)
{
int index = _tasks.FindIndex(task => task.Id == taskId);
if (index < 0)
{
return Task.FromResult(false);
}
DiscoveryTaskDto task = _tasks[index];
if (patch.Name is not null)
{
task = task with { Name = patch.Name };
}
if (patch.Description is not null)
{
task = task with { Description = patch.Description };
}
if (patch.Keywords is not null)
{
task = task with { Keywords = patch.Keywords.ToList() };
}
if (patch.MinSubscribers is int minSubscribers)
{
task = task with { MinSubscribers = minSubscribers };
}
if (patch.Lang is not null)
{
task = task with { Lang = patch.Lang };
}
if (patch.Threshold is int threshold)
{
task = task with { Threshold = threshold };
}
if (patch.SampleSize is int sampleSize)
{
task = task with { SampleSize = sampleSize };
}
if (patch.PlanJoins is int planJoins)
{
task = task with { PlanJoins = planJoins };
}
if (patch.AutoJoin is bool autoJoin)
{
task = task with { AutoJoin = autoJoin };
}
_tasks[index] = task with { UpdatedAt = _clock().ToUnixTimeMilliseconds() };
return Task.FromResult(true);
}
private async Task<bool> DeleteTaskAsync(string taskId, CancellationToken ct)
{
if (!_tasks.Any(task => task.Id == taskId))
{
return false;
}
_tasks.RemoveAll(task => task.Id == taskId);
_candidates.RemoveAll(candidate => candidate.TaskId == taskId);
_log.RemoveAll(entry => entry.TaskId == taskId);
return true;
}
private Task<bool> SetTaskRunningAsync( string taskId, bool resetProgress, CancellationToken ct)
{
int index = _tasks.FindIndex(task => task.Id == taskId);
if (index < 0)
{
return Task.FromResult(false);
}
DiscoveryTaskDto task = _tasks[index] with { Status = DiscoveryTaskStatuses.Running };
if (resetProgress)
{
task = task with
{
SearchIdx = 0,
SearchDone = false,
Found = 0,
Evaluated = 0,
Joined = 0,
Rejected = 0,
};
}
_tasks[index] = task with { UpdatedAt = _clock().ToUnixTimeMilliseconds() };
return Task.FromResult(true);
}
private Task<bool> SetTaskPausedAsync(string taskId, CancellationToken ct)
{
int index = _tasks.FindIndex(task => task.Id == taskId);
if (index < 0)
{
return Task.FromResult(false);
}
_tasks[index] = _tasks[index] with
{
Status = DiscoveryTaskStatuses.Paused,
UpdatedAt = _clock().ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
}
private Task<bool> SetTaskDoneAsync(string taskId, CancellationToken ct)
{
int index = _tasks.FindIndex(task => task.Id == taskId);
if (index < 0)
{
return Task.FromResult(false);
}
_tasks[index] = _tasks[index] with
{
Status = DiscoveryTaskStatuses.Done,
UpdatedAt = _clock().ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
}
private Task<bool> BumpTaskCounterAsync( string taskId, DiscoveryCounterField field, int n, CancellationToken ct)
{
int index = _tasks.FindIndex(task => task.Id == taskId);
if (index < 0 || n <= 0)
{
return Task.FromResult(index >= 0);
}
DiscoveryTaskDto task = field switch
{
DiscoveryCounterField.Found => _tasks[index] with { Found = _tasks[index].Found + n },
DiscoveryCounterField.Evaluated => _tasks[index] with { Evaluated = _tasks[index].Evaluated + n },
DiscoveryCounterField.Joined => _tasks[index] with { Joined = _tasks[index].Joined + n },
DiscoveryCounterField.Rejected => _tasks[index] with { Rejected = _tasks[index].Rejected + n },
_ => throw new ArgumentOutOfRangeException(nameof(field), field, "Неизвестный счётчик задачи"),
};
_tasks[index] = task with { UpdatedAt = _clock().ToUnixTimeMilliseconds() };
return Task.FromResult(true);
}
private Task<bool> AdvanceSearchAsync( string taskId, int nextIndex, bool searchDone, CancellationToken ct)
{
int index = _tasks.FindIndex(task => task.Id == taskId);
if (index < 0)
{
return Task.FromResult(false);
}
_tasks[index] = _tasks[index] with
{
SearchIdx = nextIndex,
SearchDone = searchDone,
UpdatedAt = _clock().ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
}
private Task<int> SumActivePlanAsync(string? excludeTaskId, CancellationToken ct)
{
IEnumerable<DiscoveryTaskDto> active = _tasks
.Where(task => !DiscoveryTaskStatuses.IsFinished(task.Status));
if (excludeTaskId is not null)
{
active = active.Where(task => task.Id != excludeTaskId);
}
return Task.FromResult(active.Sum(task => task.PlanJoins));
}
private Task<IReadOnlyList<DiscoveryCandidateDto>> ListCandidatesAsync( string taskId, string? status, CancellationToken ct)
{
IEnumerable<DiscoveryCandidateDto> query = _candidates.Where(candidate => candidate.TaskId == taskId);
if (status is not null)
{
query = query.Where(candidate => candidate.Status == status);
}
return Task.FromResult<IReadOnlyList<DiscoveryCandidateDto>>(query
.OrderBy(candidate => candidate.CreatedAt)
.ToList());
}
private Task<DiscoveryCandidateDto?> GetCandidateAsync(string dialogId, CancellationToken ct)
{
return Task.FromResult(_candidates.FirstOrDefault(candidate => candidate.DialogId == dialogId));
}
private Task<bool> IsDialogMonitoredAsync(string dialogId, CancellationToken ct)
{
return Task.FromResult(_monitoredIds.Contains(dialogId));
}
private Task<bool> IsBlacklistedAsync(string dialogId, CancellationToken ct)
{
return Task.FromResult(_blacklist.Any(entry => entry.DialogId == dialogId));
}
private Task CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct)
{
long nowMs = _clock().ToUnixTimeMilliseconds();
_candidates.Add(new DiscoveryCandidateDto(
row.DialogId,
row.TaskId,
row.Name,
row.Username,
row.Kind,
row.Hue,
Participants: null,
LangRu: null,
new List<string>(),
new List<DiscoveryTopicDto>(),
FitRatio: null,
DiscoveryCandidateStatuses.New,
AutoJoined: false,
JoinFailures: 0,
nowMs,
nowMs));
return Task.CompletedTask;
}
private Task DeleteCandidateAsync(string dialogId, CancellationToken ct)
{
_candidates.RemoveAll(candidate => candidate.DialogId == dialogId);
return Task.CompletedTask;
}
private Task<bool> PatchCandidateAsync( string dialogId, DiscoveryCandidatePatch patch, CancellationToken ct)
{
int index = _candidates.FindIndex(candidate => candidate.DialogId == dialogId);
if (index < 0)
{
return Task.FromResult(false);
}
DiscoveryCandidateDto candidate = _candidates[index];
if (patch.Name is not null)
{
candidate = candidate with { Name = patch.Name };
}
if (patch.Username is not null)
{
candidate = candidate with { Username = patch.Username };
}
if (patch.Kind is not null)
{
candidate = candidate with { Kind = patch.Kind };
}
if (patch.Hue is not null)
{
candidate = candidate with { Hue = patch.Hue };
}
if (patch.Participants is int participants)
{
candidate = candidate with { Participants = participants };
}
if (patch.LangRu is bool langRu)
{
candidate = candidate with { LangRu = langRu };
}
if (patch.Marks is not null)
{
candidate = candidate with { Marks = patch.Marks.ToList() };
}
if (patch.Topics is not null)
{
candidate = candidate with { Topics = patch.Topics.ToList() };
}
if (patch.FitRatio is double fitRatio)
{
candidate = candidate with { FitRatio = fitRatio };
}
if (patch.AutoJoined is bool autoJoined)
{
candidate = candidate with { AutoJoined = autoJoined };
}
_candidates[index] = candidate with { UpdatedAt = _clock().ToUnixTimeMilliseconds() };
return Task.FromResult(true);
}
private Task<bool> SetCandidateStatusAsync( string dialogId, string status, CancellationToken ct)
{
int index = _candidates.FindIndex(candidate => candidate.DialogId == dialogId);
if (index < 0)
{
return Task.FromResult(false);
}
_candidates[index] = _candidates[index] with
{
Status = status,
UpdatedAt = _clock().ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
}
private Task<bool> SetCandidateJoinedAsync( string dialogId, bool autoJoined, CancellationToken ct)
{
int index = _candidates.FindIndex(candidate => candidate.DialogId == dialogId);
if (index < 0)
{
return Task.FromResult(false);
}
_candidates[index] = _candidates[index] with
{
Status = DiscoveryCandidateStatuses.Joined,
AutoJoined = autoJoined,
UpdatedAt = _clock().ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
}
private Task<int?> IncrementJoinFailuresAsync(string dialogId, CancellationToken ct)
{
int index = _candidates.FindIndex(candidate => candidate.DialogId == dialogId);
if (index < 0 || _candidates[index].Status != DiscoveryCandidateStatuses.Review)
{
return Task.FromResult<int?>(null);
}
DiscoveryCandidateDto candidate = _candidates[index] with
{
JoinFailures = _candidates[index].JoinFailures + 1,
UpdatedAt = _clock().ToUnixTimeMilliseconds(),
};
_candidates[index] = candidate;
return Task.FromResult<int?>(candidate.JoinFailures);
}
private Task<bool> SetCandidateRejectedAsync(string dialogId, CancellationToken ct)
{
int index = _candidates.FindIndex(candidate => candidate.DialogId == dialogId);
if (index < 0)
{
return Task.FromResult(false);
}
_candidates[index] = _candidates[index] with
{
Status = DiscoveryCandidateStatuses.Rejected,
UpdatedAt = _clock().ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
}
private Task UpsertBlacklistAsync( string dialogId, string name, string reason, CancellationToken ct)
{
DiscoveryBlacklistDto? existing = _blacklist.FirstOrDefault(entry => entry.DialogId == dialogId);
if (existing is null)
{
_blacklist.Add(new DiscoveryBlacklistDto(dialogId, name, reason, _clock().ToUnixTimeMilliseconds()));
}
else
{
_blacklist[_blacklist.IndexOf(existing)] = existing with { Name = name, Reason = reason };
}
return Task.CompletedTask;
}
private Task RemoveBlacklistAsync(string dialogId, CancellationToken ct)
{
_blacklist.RemoveAll(entry => entry.DialogId == dialogId);
return Task.CompletedTask;
}
private Task<DiscoveryBlacklistDto?> GetBlacklistAsync(string dialogId, CancellationToken ct)
{
return Task.FromResult(_blacklist.FirstOrDefault(entry => entry.DialogId == dialogId));
}
private Task<IReadOnlyList<DiscoveryBlacklistDto>> ListBlacklistAsync(CancellationToken ct)
{
return Task.FromResult<IReadOnlyList<DiscoveryBlacklistDto>>(_blacklist
.OrderByDescending(entry => entry.CreatedAt)
.ToList());
}
private Task AddLogAsync( string logId, string taskId, string logEvent, string text, CancellationToken ct)
{
_log.Add(new DiscoveryLogDto(logId, taskId, logEvent, text, _clock().ToUnixTimeMilliseconds()));
return Task.CompletedTask;
}
private Task<int> CountLogEventAsync( string logEvent, DateTimeOffset sinceUtc, CancellationToken ct)
{
long sinceMs = sinceUtc.ToUnixTimeMilliseconds();
return Task.FromResult(_log.Count(entry => entry.Event == logEvent && entry.CreatedAt >= sinceMs));
}
private Task<IReadOnlyList<DiscoveryLogDto>> ListTaskLogAsync( string taskId, int limit, CancellationToken ct)
{
IReadOnlyList<DiscoveryLogDto> entries = _log
.Where(entry => entry.TaskId == taskId)
.OrderByDescending(entry => entry.CreatedAt)
.Take(limit)
.ToList();
return Task.FromResult(entries);
}
}