Хелпер Support/TestDiscoveryStore сгенерирован из фейка: состояние (tasks/ candidates/blacklist/log/monitored) сохранено, 29 методов порта читают состояние на момент вызова, неожиданных методов нет. Потребители (8 файлов) перетипизированы на .Store, фейк удалён, тесты 1340 зелёные.
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
+25
-24
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user