Перевести 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(
DiscoveryWorkerScheduler Scheduler,
FakeDiscoveryStore StoreA,
FakeDiscoveryStore StoreB,
TestDiscoveryStore StoreA,
TestDiscoveryStore StoreB,
TestDiscoveryGateway GatewayA,
TestDiscoveryGateway GatewayB,
TenantContext TenantContext,
@@ -92,8 +92,8 @@ public sealed class DiscoveryWorkerSchedulerTests
{
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
var tenantContext = new TenantContext();
var storeA = new FakeDiscoveryStore();
var storeB = new FakeDiscoveryStore();
var storeA = new TestDiscoveryStore();
var storeB = new TestDiscoveryStore();
var settingsA = new FakeSettingsStore();
var settingsB = new FakeSettingsStore();
var gatewayA = new TestDiscoveryGateway();
@@ -108,7 +108,7 @@ public sealed class DiscoveryWorkerSchedulerTests
services.AddSingleton<IAiTools>(_ => TestAiTools.New());
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
// (эталон 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<ITelegramGateway>(provider => TenantOf(provider) == TenantA ? gatewayA.Gateway : gatewayB.Gateway);
// Реальные сервисы модуля Discovery — как AddDiscoveryModule в Program.cs: цикл резолвит их в tenant-scope.
@@ -418,7 +418,7 @@ public sealed class DiscoveryWorkerServiceTests
// Контекст теста: воркер на фейках + ссылки для проверок состояния.
private sealed record Fixture(
FakeDiscoveryStore Store,
TestDiscoveryStore Store,
FakeSettingsStore Settings,
TestDiscoveryGateway Gateway,
IDiscoveryPacer Pacer,
@@ -428,22 +428,22 @@ public sealed class DiscoveryWorkerServiceTests
// Создаёт воркер на общих фейках; ML/ИИ выключены — оценка идёт эвристикой по ключам.
private static Fixture Create()
{
var store = new FakeDiscoveryStore();
var store = new TestDiscoveryStore();
var settings = new FakeSettingsStore();
settings.Preload(SettingsKeys.MlEnabled, "false");
settings.Preload(SettingsKeys.AiEnabled, "false");
var ml = new FakeMlClient();
var ai = TestAiTools.New();
var log = new DiscoveryLogService(store);
var blacklist = new DiscoveryBlacklistService(store);
var candidates = new DiscoveryCandidatesService(store, log, blacklist);
var planGuard = new DiscoveryPlanGuard(store, settings);
var tasks = new DiscoveryTasksService(store, planGuard, settings);
var banGuard = new DiscoveryBanGuard(store, settings);
var log = new DiscoveryLogService(store.Store);
var blacklist = new DiscoveryBlacklistService(store.Store);
var candidates = new DiscoveryCandidatesService(store.Store, log, blacklist);
var planGuard = new DiscoveryPlanGuard(store.Store, settings);
var tasks = new DiscoveryTasksService(store.Store, planGuard, settings);
var banGuard = new DiscoveryBanGuard(store.Store, settings);
var pacer = Substitute.For<IDiscoveryPacer>();
var gateway = new TestDiscoveryGateway();
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);
}
@@ -1,4 +1,5 @@
using Deal.Modules.Discovery.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Discovery.Application.Services;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Modules.Settings;
@@ -28,7 +29,7 @@ public sealed class DiscoveryBanGuardTests
[Fact]
public async Task CanAutoJoin_LimitReachedToday_Blocked()
{
(DiscoveryBanGuard guard, FakeDiscoveryStore store, _) = Create();
(DiscoveryBanGuard guard, TestDiscoveryStore store, _) = Create();
for (int i = 0; i < 50; i++)
{
@@ -38,9 +39,9 @@ public sealed class DiscoveryBanGuardTests
Assert.False(await guard.CanAutoJoinAsync(CancellationToken.None));
// Вчерашние вступления в суточную квоту не входят: 49 сегодня → ещё можно.
FakeDiscoveryStore yesterdayStore = new(() => Now);
TestDiscoveryStore yesterdayStore = new(() => Now);
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++)
{
yesterdayStore.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddMinutes(i));
@@ -53,7 +54,7 @@ public sealed class DiscoveryBanGuardTests
[Fact]
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(2));
@@ -98,7 +99,7 @@ public sealed class DiscoveryBanGuardTests
[Fact]
public async Task CustomJoinLimit_AppliedWhenSettingStored()
{
(DiscoveryBanGuard guard, FakeDiscoveryStore store, FakeSettingsStore settings) = Create();
(DiscoveryBanGuard guard, TestDiscoveryStore store, FakeSettingsStore settings) = Create();
settings.Preload(SettingsKeys.DiscJoinLimit, "2");
Assert.True(await guard.CanAutoJoinAsync(CancellationToken.None));
@@ -108,11 +109,11 @@ public sealed class DiscoveryBanGuardTests
}
// Создаёт связку гард + фейки на фиксированных «часах» (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 guard = new DiscoveryBanGuard(store, settings, () => Now);
var guard = new DiscoveryBanGuard(store.Store, settings, () => Now);
return (guard, store, settings);
}
}
@@ -1,4 +1,5 @@
using Deal.Modules.Discovery.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Discovery.Application.Services;
namespace Deal.Tests.Unit.Modules.Discovery;
@@ -11,7 +12,7 @@ public sealed class DiscoveryBlacklistServiceTests
[Fact]
public async Task Add_EmptyName_UsesDialogId()
{
(DiscoveryBlacklistService service, FakeDiscoveryStore store) = Create();
(DiscoveryBlacklistService service, TestDiscoveryStore store) = Create();
DiscoveryBlacklistDto entry = await service.AddAsync("-1001", "", "отклонён", CancellationToken.None);
@@ -24,8 +25,8 @@ public sealed class DiscoveryBlacklistServiceTests
public async Task Add_OverwriteUpdatesNameAndReason_KeepsCreatedAt()
{
long tick = 0;
var store = new FakeDiscoveryStore(() => DateTimeOffset.FromUnixTimeMilliseconds(++tick * 1000L));
var service = new DiscoveryBlacklistService(store);
var store = new TestDiscoveryStore(() => DateTimeOffset.FromUnixTimeMilliseconds(++tick * 1000L));
var service = new DiscoveryBlacklistService(store.Store);
// Первая запись источника — CreatedAt фиксируется (t=1000).
DiscoveryBlacklistDto first = await service.AddAsync("-1001", "Первое имя", "отклонено вручную", CancellationToken.None);
@@ -41,7 +42,7 @@ public sealed class DiscoveryBlacklistServiceTests
[Fact]
public async Task Remove_DeletesRow()
{
(DiscoveryBlacklistService service, FakeDiscoveryStore store) = Create();
(DiscoveryBlacklistService service, TestDiscoveryStore store) = Create();
await service.AddAsync("-1001", "Канал", "причина", CancellationToken.None);
await service.RemoveAsync("-1001", CancellationToken.None);
@@ -53,8 +54,8 @@ public sealed class DiscoveryBlacklistServiceTests
public async Task List_ReturnsNewestFirst()
{
long tick = 0;
var store = new FakeDiscoveryStore(() => DateTimeOffset.FromUnixTimeMilliseconds(++tick * 1000L));
var service = new DiscoveryBlacklistService(store);
var store = new TestDiscoveryStore(() => DateTimeOffset.FromUnixTimeMilliseconds(++tick * 1000L));
var service = new DiscoveryBlacklistService(store.Store);
await service.AddAsync("-1001", "Старый", "r1", CancellationToken.None); // t=1000
await service.AddAsync("-1002", "Новый", "r2", CancellationToken.None); // t=2000
@@ -65,9 +66,9 @@ public sealed class DiscoveryBlacklistServiceTests
}
// Создаёт связку blacklist-сервиса + фейк.
private static (DiscoveryBlacklistService Service, FakeDiscoveryStore Store) Create()
private static (DiscoveryBlacklistService Service, TestDiscoveryStore Store) Create()
{
var store = new FakeDiscoveryStore();
return (new DiscoveryBlacklistService(store), store);
var store = new TestDiscoveryStore();
return (new DiscoveryBlacklistService(store.Store), store);
}
}
@@ -1,5 +1,6 @@
using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Discovery.Application.Services;
namespace Deal.Tests.Unit.Modules.Discovery;
@@ -12,7 +13,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact]
public async Task Add_NewSource_CreatesNewCandidateAndBumpsFound()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
(DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
DiscoveryCandidateDto? candidate = await service.AddAsync(
@@ -34,7 +35,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact]
public async Task Add_EmptyNameAndKind_UsesDefaults()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
(DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
DiscoveryCandidateDto? candidate = await service.AddAsync(
@@ -49,7 +50,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact]
public async Task Add_AlreadyMonitored_SkipsWithLog()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
(DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
store.SeedMonitored("-1001");
@@ -67,9 +68,9 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact]
public async Task Add_Blacklisted_SkipsWithLog()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
(DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
await store.UpsertBlacklistAsync("-1001", "Канал", "отклонён вручную", CancellationToken.None);
await store.Store.UpsertBlacklistAsync("-1001", "Канал", "отклонён вручную", CancellationToken.None);
DiscoveryCandidateDto? candidate = await service.AddAsync(
"dt_1", "-1001", "Канал", "", DiscoveryCandidateKinds.Channel, "", CancellationToken.None);
@@ -82,7 +83,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact]
public async Task Add_AlreadyActiveCandidate_SkipsWithLog()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
(DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1", status: DiscoveryCandidateStatuses.Review));
@@ -98,7 +99,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact]
public async Task Add_StaleRejectedRow_IsReplacedByNewCandidate()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
(DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1", status: DiscoveryCandidateStatuses.Rejected));
@@ -115,7 +116,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact]
public async Task Add_MissingTask_ReturnsNullWithoutLog()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
(DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
DiscoveryCandidateDto? candidate = await service.AddAsync(
"dt_missing", "-1001", "Канал", "", DiscoveryCandidateKinds.Channel, "", CancellationToken.None);
@@ -128,7 +129,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact]
public async Task MarkJoined_Auto_SetsJoinedCountersAndLog()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
(DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1"));
@@ -146,7 +147,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact]
public async Task MarkJoined_Manual_LogsJoinManual()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
(DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1"));
@@ -161,7 +162,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact]
public async Task MarkJoined_AlreadyJoined_IsIdempotent()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
(DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1", status: DiscoveryCandidateStatuses.Joined));
@@ -186,7 +187,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact]
public async Task MarkRejected_WritesRejectedCountersLogAndBlacklist()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
(DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1", name: "Канал"));
@@ -208,7 +209,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact]
public async Task MarkRejected_AlreadyRejected_IsIdempotent()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
(DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1", status: DiscoveryCandidateStatuses.Rejected));
@@ -224,7 +225,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact]
public async Task MarkRejected_JoinedCandidate_Throws()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
(DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1", status: DiscoveryCandidateStatuses.Joined));
@@ -250,7 +251,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact]
public async Task SetStatus_ToReview_WritesReviewLog()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
(DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1"));
@@ -267,7 +268,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact]
public async Task SetStatus_Joined_Throws()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
(DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1"));
@@ -282,7 +283,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact]
public async Task Set_UpdatesEvaluationFieldsAndKeepsEmptyName()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
(DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1"));
store.SeedCandidate(Candidate("-1001", "dt_1", name: "Канал"));
@@ -316,7 +317,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact]
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_2"));
store.SeedCandidate(Candidate("-1001", "dt_1"));
@@ -330,7 +331,7 @@ public sealed class DiscoveryCandidatesServiceTests
[Fact]
public async Task Delete_RemovesCandidateRow()
{
(DiscoveryCandidatesService service, FakeDiscoveryStore store) = Create();
(DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedCandidate(Candidate("-1001", "dt_1"));
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 log = new DiscoveryLogService(store);
var blacklist = new DiscoveryBlacklistService(store);
return (new DiscoveryCandidatesService(store, log, blacklist), store);
var store = new TestDiscoveryStore();
var log = new DiscoveryLogService(store.Store);
var blacklist = new DiscoveryBlacklistService(store.Store);
return (new DiscoveryCandidatesService(store.Store, log, blacklist), store);
}
// Задача как строка DiscTasks (running; счётчики 0).
@@ -1,4 +1,5 @@
using Deal.Modules.Discovery.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Discovery.Application.Services;
namespace Deal.Tests.Unit.Modules.Discovery;
@@ -11,8 +12,8 @@ public sealed class DiscoveryLogServiceTests
[Fact]
public async Task Add_WritesRowWithDlPrefix()
{
var store = new FakeDiscoveryStore();
var service = new DiscoveryLogService(store);
var store = new TestDiscoveryStore();
var service = new DiscoveryLogService(store.Store);
await service.AddAsync("dt_1", DiscoveryLogEvents.Skip, "пропущен -1001: причина", CancellationToken.None);
@@ -28,8 +29,8 @@ public sealed class DiscoveryLogServiceTests
public async Task TaskLog_ReturnsNewestFirst()
{
long tick = 0;
var store = new FakeDiscoveryStore(() => DateTimeOffset.FromUnixTimeMilliseconds(++tick * 1000L));
var service = new DiscoveryLogService(store);
var store = new TestDiscoveryStore(() => DateTimeOffset.FromUnixTimeMilliseconds(++tick * 1000L));
var service = new DiscoveryLogService(store.Store);
await service.AddAsync("dt_1", DiscoveryLogEvents.Search, "первое", CancellationToken.None); // t=1000
await service.AddAsync("dt_1", DiscoveryLogEvents.JoinAuto, "второе", CancellationToken.None); // t=2000
@@ -44,8 +45,8 @@ public sealed class DiscoveryLogServiceTests
[Fact]
public async Task TaskLog_EmptyTask_ReturnsEmpty()
{
var store = new FakeDiscoveryStore();
var service = new DiscoveryLogService(store);
var store = new TestDiscoveryStore();
var service = new DiscoveryLogService(store.Store);
IReadOnlyList<DiscoveryLogDto> items = await service.TaskLogAsync("dt_no_log", CancellationToken.None);
@@ -56,8 +57,8 @@ public sealed class DiscoveryLogServiceTests
public async Task TaskLog_LimitTakesNewest()
{
long tick = 0;
var store = new FakeDiscoveryStore(() => DateTimeOffset.FromUnixTimeMilliseconds(++tick * 1000L));
var service = new DiscoveryLogService(store);
var store = new TestDiscoveryStore(() => DateTimeOffset.FromUnixTimeMilliseconds(++tick * 1000L));
var service = new DiscoveryLogService(store.Store);
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.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Discovery.Application.Services;
using Deal.Tests.Unit.Modules.Settings;
@@ -13,7 +14,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact]
public async Task Create_EmptyName_ThrowsAndCreatesNothing()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
(DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
DiscoveryValidationException error = await Assert.ThrowsAsync<DiscoveryValidationException>(
() => service.CreateAsync(new DiscoveryTaskDraft { Name = " " }, CancellationToken.None));
@@ -25,7 +26,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact]
public async Task Create_PlanBelowOne_Throws()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
(DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
DiscoveryValidationException error = await Assert.ThrowsAsync<DiscoveryValidationException>(
() => service.CreateAsync(NewDraft(planJoins: 0), CancellationToken.None));
@@ -37,7 +38,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact]
public async Task Create_PlanAboveDailyLimit_Throws()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
(DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
DiscoveryValidationException error = await Assert.ThrowsAsync<DiscoveryValidationException>(
() => service.CreateAsync(NewDraft(planJoins: 51), CancellationToken.None));
@@ -49,7 +50,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact]
public async Task Create_TaskAtLimitLeavesNoBudget_Throws()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
(DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1", planJoins: 50));
DiscoveryValidationException error = await Assert.ThrowsAsync<DiscoveryValidationException>(
@@ -62,7 +63,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact]
public async Task Create_RemainingQuota_FillsUpToLimitAndRejectsOver()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
(DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1", planJoins: 25));
// 25 + 25 = 50 — вторая задача на 25 влезает (сумма планов ≤ discJoinLimit).
@@ -79,7 +80,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact]
public async Task Create_NormalizesAndCreatesDraft()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
(DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
DiscoveryTaskDto task = await service.CreateAsync(
new DiscoveryTaskDraft
@@ -116,7 +117,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact]
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("discEvalSample", "20");
@@ -143,7 +144,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact]
public async Task Patch_AppliesFieldsAndClamps()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
(DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1", keywords: new[] { "старое" }));
DiscoveryTaskDto? task = await service.PatchAsync(
@@ -170,7 +171,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact]
public async Task Patch_NullOnlyPatch_ReturnsCurrentWithoutWrite()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
(DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1", name: "Старое"));
DiscoveryTaskDto? task = await service.PatchAsync("dt_1", new DiscoveryTaskPatch(), CancellationToken.None);
@@ -183,7 +184,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact]
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_2", planJoins: 20));
@@ -212,7 +213,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact]
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>()));
DiscoveryValidationException error = await Assert.ThrowsAsync<DiscoveryValidationException>(
@@ -224,7 +225,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact]
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));
DiscoveryTaskDto? task = await service.StartAsync("dt_1", CancellationToken.None);
@@ -238,7 +239,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact]
public async Task Start_FromDone_ResetsSearchProgress()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
(DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
store.SeedTask(Task(
"dt_1",
status: DiscoveryTaskStatuses.Done,
@@ -261,7 +262,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact]
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" }));
DiscoveryTaskDto? task = await service.PauseAsync("dt_1", CancellationToken.None);
@@ -273,12 +274,12 @@ public sealed class DiscoveryTasksServiceTests
[Fact]
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_2"));
store.SeedCandidate(Candidate("c_1", "dt_1"));
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);
@@ -303,7 +304,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact]
public async Task AdvanceSearch_MovesIndexAndFinishes()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
(DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1", keywords: new[] { "a", "b" }));
Assert.True(await service.AdvanceSearchAsync("dt_1", CancellationToken.None));
@@ -320,7 +321,7 @@ public sealed class DiscoveryTasksServiceTests
[Fact]
public async Task BumpCounter_IncrementsField()
{
(DiscoveryTasksService service, FakeDiscoveryStore store, _) = Create();
(DiscoveryTasksService service, TestDiscoveryStore store, _) = Create();
store.SeedTask(Task("dt_1"));
Assert.True(await service.BumpCounterAsync("dt_1", DiscoveryCounterField.Evaluated, 3, CancellationToken.None));
@@ -329,12 +330,12 @@ public sealed class DiscoveryTasksServiceTests
}
// Создаёт связку сервис + фейки (пустые настройки → дефолты 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 planGuard = new DiscoveryPlanGuard(store, settings);
return (new DiscoveryTasksService(store, planGuard, settings), store, settings);
var planGuard = new DiscoveryPlanGuard(store.Store, settings);
return (new DiscoveryTasksService(store.Store, planGuard, settings), store, settings);
}
// 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);
}
}