Перевести тестовые фейки на моки NSubstitute #13
@@ -1,10 +1,27 @@
|
||||
name: ci
|
||||
|
||||
# Прогоняем CI в двух случаях:
|
||||
# 1) МР: создан, переоткрыт или в ветку МР запушены новые коммиты (synchronize).
|
||||
# 2) Влитие в main (пуш в main — в т.ч. мерж МР).
|
||||
# Обычные коммиты в ветки без открытого МР CI не запускают.
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
pull_request:
|
||||
types:
|
||||
- opened
|
||||
- synchronize
|
||||
- reopened
|
||||
workflow_dispatch:
|
||||
|
||||
# Новый прогон того же рефа (та же ветка МР / тот же main) отменяет незавершённый предыдущий:
|
||||
# коммит-А стартовал, через пару минут коммит-Б — прогон А отменяется, если ещё не закончил.
|
||||
# Завершённые (успех/фейл) прогоны не трогаются.
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
build-test:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
@@ -20,7 +20,7 @@ public sealed class DiscoveryBanGuard
|
||||
/// </summary>
|
||||
/// <param name="store">Хранилище Discovery (счётчик DiscLog за сутки).</param>
|
||||
/// <param name="settings">KV-настройки тенанта (discJoinLimit/discFloodDay/discPaused).</param>
|
||||
/// <param name="utcNow">Источник текущего времени (UTC; тесты передают фиксированные «часы», эталон FakeDiscoveryStore). По умолчанию — <see cref="DateTimeOffset.UtcNow"/>.</param>
|
||||
/// <param name="utcNow">Источник текущего времени (UTC). По умолчанию — <see cref="DateTimeOffset.UtcNow"/>.</param>
|
||||
public DiscoveryBanGuard(
|
||||
IDiscoveryStore store,
|
||||
ISettingsStore settings,
|
||||
|
||||
@@ -24,11 +24,11 @@ public sealed class DataRetentionSchedulerTests
|
||||
public async Task RunCycle_PurgesAgedAuditResetsExpiredLimitsAndDeletesExpiredCounters()
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
var audit = new FakeAuditLogStore();
|
||||
await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 20))), CancellationToken.None);
|
||||
await audit.AppendAsync(AuditRecord(AuditEvents.TenantLoginOk, now.AddDays(-1)), CancellationToken.None);
|
||||
var audit = new TestAuditLogStore();
|
||||
await audit.Store.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 20))), CancellationToken.None);
|
||||
await audit.Store.AppendAsync(AuditRecord(AuditEvents.TenantLoginOk, now.AddDays(-1)), CancellationToken.None);
|
||||
|
||||
var limits = new FakeTenantLimitStore();
|
||||
var limits = new TestTenantLimitStore();
|
||||
// Период месяца начат два месяца назад → истёк; строка с накоплениями должна обнулиться.
|
||||
limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 700, warned80: true);
|
||||
|
||||
@@ -63,9 +63,9 @@ public sealed class DataRetentionSchedulerTests
|
||||
public async Task RunCycle_IsIdempotent()
|
||||
{
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
var audit = new FakeAuditLogStore();
|
||||
await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None);
|
||||
var limits = new FakeTenantLimitStore();
|
||||
var audit = new TestAuditLogStore();
|
||||
await audit.Store.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None);
|
||||
var limits = new TestTenantLimitStore();
|
||||
limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 500);
|
||||
await using ServiceProvider provider = BuildProvider(audit, limits, new TestRateLimitCounterStore());
|
||||
DataRetentionScheduler scheduler = new(
|
||||
@@ -86,13 +86,13 @@ public sealed class DataRetentionSchedulerTests
|
||||
// counters: Хелпер счётчиков (окна посеяны сценарием).
|
||||
// Возвращает: Провайдер с сервисами цикла.
|
||||
private static ServiceProvider BuildProvider(
|
||||
FakeAuditLogStore audit,
|
||||
FakeTenantLimitStore limits,
|
||||
TestAuditLogStore audit,
|
||||
TestTenantLimitStore limits,
|
||||
TestRateLimitCounterStore counters)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<IAuditLogStore>(_ => audit);
|
||||
services.AddScoped<ITenantLimitStore>(_ => limits);
|
||||
services.AddScoped<IAuditLogStore>(_ => audit.Store);
|
||||
services.AddScoped<ITenantLimitStore>(_ => limits.Store);
|
||||
services.AddScoped<IRateLimitCounterStore>(_ => counters.Store);
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
@@ -94,8 +94,8 @@ public sealed class DiscoveryWorkerSchedulerTests
|
||||
var tenantContext = new TenantContext();
|
||||
var storeA = new TestDiscoveryStore();
|
||||
var storeB = new TestDiscoveryStore();
|
||||
var settingsA = new FakeSettingsStore();
|
||||
var settingsB = new FakeSettingsStore();
|
||||
var settingsA = new TestSettingsStore();
|
||||
var settingsB = new TestSettingsStore();
|
||||
var gatewayA = new TestDiscoveryGateway();
|
||||
var gatewayB = new TestDiscoveryGateway();
|
||||
PreloadDiscoveryEvalOff(settingsA);
|
||||
@@ -104,12 +104,12 @@ public sealed class DiscoveryWorkerSchedulerTests
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<ITenantContext>(tenantContext);
|
||||
services.AddSingleton<ITenantRepository>(tenants.Repository);
|
||||
services.AddSingleton<IMlClient>(new FakeMlClient());
|
||||
services.AddSingleton<IMlClient>(new TestMlClient().Client);
|
||||
services.AddSingleton<IAiTools>(_ => TestAiTools.New());
|
||||
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
|
||||
// (эталон PipelineWorkerSchedulerTests/ConnectionStringProvider.ForTenant).
|
||||
services.AddScoped<IDiscoveryStore>(provider => TenantOf(provider) == TenantA ? storeA.Store : storeB.Store);
|
||||
services.AddScoped<ISettingsStore>(provider => TenantOf(provider) == TenantA ? settingsA : settingsB);
|
||||
services.AddScoped<ISettingsStore>(provider => TenantOf(provider) == TenantA ? settingsA.Store : settingsB.Store);
|
||||
services.AddScoped<ITelegramGateway>(provider => TenantOf(provider) == TenantA ? gatewayA.Gateway : gatewayB.Gateway);
|
||||
// Реальные сервисы модуля Discovery — как AddDiscoveryModule в Program.cs: цикл резолвит их в tenant-scope.
|
||||
services.AddDiscoveryModule();
|
||||
@@ -124,7 +124,7 @@ public sealed class DiscoveryWorkerSchedulerTests
|
||||
}
|
||||
|
||||
// ML/ИИ выключены в настройках тенанта (оценка содержания — эвристика по ключам).
|
||||
private static void PreloadDiscoveryEvalOff(FakeSettingsStore settings)
|
||||
private static void PreloadDiscoveryEvalOff(TestSettingsStore settings)
|
||||
{
|
||||
settings.Preload(SettingsKeys.MlEnabled, "false");
|
||||
settings.Preload(SettingsKeys.AiEnabled, "false");
|
||||
|
||||
@@ -49,7 +49,7 @@ public sealed class IngressRateLimitInterceptorTests
|
||||
|
||||
await RunAsync(
|
||||
registry,
|
||||
services => services.AddScoped<IPipelineStore>(_ => new FakePipelineStore()),
|
||||
services => services.AddScoped<IPipelineStore>(_ => new TestPipelineStore().Store),
|
||||
async channel =>
|
||||
{
|
||||
PushSourceReply first = await PushAsync(channel, TenantA);
|
||||
@@ -77,7 +77,7 @@ public sealed class IngressRateLimitInterceptorTests
|
||||
|
||||
await RunAsync(
|
||||
registry,
|
||||
services => services.AddScoped<IPipelineStore>(_ => new FakePipelineStore()),
|
||||
services => services.AddScoped<IPipelineStore>(_ => new TestPipelineStore().Store),
|
||||
async channel =>
|
||||
{
|
||||
// Окно ингресса — 1/мин: единственный разрешённый вызов исчерпывает лимит.
|
||||
|
||||
@@ -39,14 +39,14 @@ public sealed class MlOutboxFlushSchedulerTests
|
||||
{
|
||||
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
||||
{
|
||||
var store = new FakeMlLearningStore();
|
||||
var store = new TestMlLearningStore();
|
||||
SeedRows(store, count: 25, prefix: "a");
|
||||
var tenantContext = new TenantContext();
|
||||
await using ServiceProvider provider = BuildProvider(
|
||||
port,
|
||||
new TestTenantRepository(Tenant(TenantA)),
|
||||
tenantContext,
|
||||
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store });
|
||||
new Dictionary<Guid, TestMlLearningStore> { [TenantA] = store });
|
||||
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
|
||||
|
||||
await scheduler.RunCycleAsync(CancellationToken.None);
|
||||
@@ -54,7 +54,7 @@ public sealed class MlOutboxFlushSchedulerTests
|
||||
// 25 строк → 3 батча по 10/10/5 (ml_client.flush_outbox: chunk=10), строки удалены после успеха.
|
||||
Assert.Equal(3, service.TrainCalls);
|
||||
Assert.Equal(new[] { 10, 10, 5 }, service.TrainBatches.Select(batch => batch.Items.Count).ToArray());
|
||||
Assert.Equal(0, await store.CountOutboxAsync(CancellationToken.None));
|
||||
Assert.Equal(0, await store.Store.CountOutboxAsync(CancellationToken.None));
|
||||
Assert.False(tenantContext.HasTenant); // контекст AsyncLocal не переживает проход
|
||||
});
|
||||
}
|
||||
@@ -65,23 +65,23 @@ public sealed class MlOutboxFlushSchedulerTests
|
||||
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
||||
{
|
||||
service.TrainUnavailable = true;
|
||||
var store = new FakeMlLearningStore();
|
||||
var store = new TestMlLearningStore();
|
||||
SeedRows(store, count: 5, prefix: "a");
|
||||
var tenantContext = new TenantContext();
|
||||
await using ServiceProvider provider = BuildProvider(
|
||||
port,
|
||||
new TestTenantRepository(Tenant(TenantA)),
|
||||
tenantContext,
|
||||
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store });
|
||||
new Dictionary<Guid, TestMlLearningStore> { [TenantA] = store });
|
||||
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
|
||||
|
||||
await scheduler.RunCycleAsync(CancellationToken.None);
|
||||
Assert.Equal(5, await store.CountOutboxAsync(CancellationToken.None)); // строки остались
|
||||
Assert.Equal(5, await store.Store.CountOutboxAsync(CancellationToken.None)); // строки остались
|
||||
|
||||
// Следующий цикл — повторная попытка (ретрай на каждом тике; строки снова не удалены).
|
||||
await scheduler.RunCycleAsync(CancellationToken.None);
|
||||
Assert.Equal(2, service.TrainCalls);
|
||||
Assert.Equal(5, await store.CountOutboxAsync(CancellationToken.None));
|
||||
Assert.Equal(5, await store.Store.CountOutboxAsync(CancellationToken.None));
|
||||
Assert.False(tenantContext.HasTenant);
|
||||
});
|
||||
}
|
||||
@@ -91,16 +91,16 @@ public sealed class MlOutboxFlushSchedulerTests
|
||||
{
|
||||
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
||||
{
|
||||
var storeA = new FakeMlLearningStore();
|
||||
var storeA = new TestMlLearningStore();
|
||||
SeedRows(storeA, count: 12, prefix: "a");
|
||||
var storeB = new FakeMlLearningStore();
|
||||
var storeB = new TestMlLearningStore();
|
||||
SeedRows(storeB, count: 3, prefix: "b");
|
||||
var tenantContext = new TenantContext();
|
||||
await using ServiceProvider provider = BuildProvider(
|
||||
port,
|
||||
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)),
|
||||
tenantContext,
|
||||
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = storeA, [TenantB] = storeB });
|
||||
new Dictionary<Guid, TestMlLearningStore> { [TenantA] = storeA, [TenantB] = storeB });
|
||||
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
|
||||
|
||||
await scheduler.RunCycleAsync(CancellationToken.None);
|
||||
@@ -108,8 +108,8 @@ public sealed class MlOutboxFlushSchedulerTests
|
||||
// Каждый тенант выгрузил свою очередь в собственном scope (A: 10+2, B: 3) — строки удалены.
|
||||
Assert.Equal(3, service.TrainCalls);
|
||||
Assert.Equal(new[] { 10, 2, 3 }, service.TrainBatches.Select(batch => batch.Items.Count).ToArray());
|
||||
Assert.Equal(0, await storeA.CountOutboxAsync(CancellationToken.None));
|
||||
Assert.Equal(0, await storeB.CountOutboxAsync(CancellationToken.None));
|
||||
Assert.Equal(0, await storeA.Store.CountOutboxAsync(CancellationToken.None));
|
||||
Assert.Equal(0, await storeB.Store.CountOutboxAsync(CancellationToken.None));
|
||||
Assert.False(tenantContext.HasTenant);
|
||||
});
|
||||
}
|
||||
@@ -119,20 +119,20 @@ public sealed class MlOutboxFlushSchedulerTests
|
||||
{
|
||||
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
||||
{
|
||||
var store = new FakeMlLearningStore();
|
||||
var store = new TestMlLearningStore();
|
||||
SeedRows(store, count: 105, prefix: "a");
|
||||
var tenantContext = new TenantContext();
|
||||
await using ServiceProvider provider = BuildProvider(
|
||||
port,
|
||||
new TestTenantRepository(Tenant(TenantA)),
|
||||
tenantContext,
|
||||
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store });
|
||||
new Dictionary<Guid, TestMlLearningStore> { [TenantA] = store });
|
||||
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
|
||||
|
||||
await scheduler.RunCycleAsync(CancellationToken.None);
|
||||
|
||||
Assert.Equal(10, service.TrainCalls);
|
||||
Assert.Equal(5, await store.CountOutboxAsync(CancellationToken.None));
|
||||
Assert.Equal(5, await store.Store.CountOutboxAsync(CancellationToken.None));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -149,7 +149,7 @@ public sealed class MlOutboxFlushSchedulerTests
|
||||
int port,
|
||||
TestTenantRepository tenants,
|
||||
TenantContext tenantContext,
|
||||
Dictionary<Guid, FakeMlLearningStore> storesByTenant)
|
||||
Dictionary<Guid, TestMlLearningStore> storesByTenant)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton<ITenantContext>(tenantContext);
|
||||
@@ -162,9 +162,9 @@ public sealed class MlOutboxFlushSchedulerTests
|
||||
services.AddScoped<IMlClient>(provider => provider.GetRequiredService<GrpcMlClient>());
|
||||
services.AddScoped<IMlTrainClient>(provider => provider.GetRequiredService<GrpcMlClient>());
|
||||
// Tenant-scoped адаптеры: выбирают фейк по тому же ITenantContext, который планировщик заполняет SetTenant.
|
||||
services.AddScoped<IMlLearningStore>(provider => storesByTenant[TenantOf(provider)]);
|
||||
services.AddScoped<ISettingsStore>(_ => new FakeSettingsStore());
|
||||
services.AddScoped<ITenantLimitStore>(_ => new FakeTenantLimitStore());
|
||||
services.AddScoped<IMlLearningStore>(provider => storesByTenant[TenantOf(provider)].Store);
|
||||
services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store);
|
||||
services.AddScoped<ITenantLimitStore>(_ => new TestTenantLimitStore().Store);
|
||||
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
|
||||
services.AddScoped<TokenUsageRecorder>();
|
||||
services.AddLogging();
|
||||
@@ -196,7 +196,7 @@ public sealed class MlOutboxFlushSchedulerTests
|
||||
// count: Число строк.
|
||||
// prefix: Префикс id (различает тенантов теста).
|
||||
private static void SeedRows(
|
||||
FakeMlLearningStore store,
|
||||
TestMlLearningStore store,
|
||||
int count,
|
||||
string prefix)
|
||||
{
|
||||
|
||||
@@ -128,13 +128,13 @@ public sealed class OperatorBootstrapHostedServiceTests
|
||||
IConfiguration configuration = new ConfigurationBuilder()
|
||||
.AddInMemoryCollection(values)
|
||||
.Build();
|
||||
var store = new FakeOperatorAuthStore();
|
||||
var store = new TestOperatorAuthStore();
|
||||
var passwordHasher = TestHashers.New();
|
||||
var logger = new ListLogger();
|
||||
// Hosted-шаг резолвит scoped OperatorBootstrapService из scope (как TenantBootstrapService):
|
||||
// провайдер собирается с реальной регистрацией модуля поверх фейков.
|
||||
var services = new ServiceCollection();
|
||||
services.AddScoped<IOperatorAuthStore>(_ => store);
|
||||
services.AddScoped<IOperatorAuthStore>(_ => store.Store);
|
||||
services.AddScoped<IPasswordHasher>(_ => passwordHasher);
|
||||
services.AddScoped<OperatorBootstrapService>();
|
||||
// Провайдер живёт всё время теста (без using): hosted резолвит scope в StartAsync после создания.
|
||||
@@ -150,7 +150,7 @@ public sealed class OperatorBootstrapHostedServiceTests
|
||||
// Контекст теста bootstrap-шага.
|
||||
private sealed record Context(
|
||||
OperatorBootstrapHostedService Hosted,
|
||||
FakeOperatorAuthStore Store,
|
||||
TestOperatorAuthStore Store,
|
||||
IPasswordHasher PasswordHasher,
|
||||
ListLogger Logs);
|
||||
|
||||
|
||||
@@ -29,19 +29,19 @@ public sealed class RuntimeDepthsCollectorTests
|
||||
[Fact]
|
||||
public async Task CollectAsync_SumsQueueAndOutboxAcrossTenants()
|
||||
{
|
||||
var pipelineByTenant = new Dictionary<string, FakePipelineStore>
|
||||
var pipelineByTenant = new Dictionary<string, TestPipelineStore>
|
||||
{
|
||||
[TenantA.ToString("N")] = new FakePipelineStore(),
|
||||
[TenantB.ToString("N")] = new FakePipelineStore(),
|
||||
[TenantA.ToString("N")] = new TestPipelineStore(),
|
||||
[TenantB.ToString("N")] = new TestPipelineStore(),
|
||||
};
|
||||
pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a1"));
|
||||
pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a2"));
|
||||
pipelineByTenant[TenantB.ToString("N")].SeedQueue(QueueItem("p_b1"));
|
||||
|
||||
var outboxByTenant = new Dictionary<string, FakeMlLearningStore>
|
||||
var outboxByTenant = new Dictionary<string, TestMlLearningStore>
|
||||
{
|
||||
[TenantA.ToString("N")] = new FakeMlLearningStore(),
|
||||
[TenantB.ToString("N")] = new FakeMlLearningStore(),
|
||||
[TenantA.ToString("N")] = new TestMlLearningStore(),
|
||||
[TenantB.ToString("N")] = new TestMlLearningStore(),
|
||||
};
|
||||
outboxByTenant[TenantA.ToString("N")].SeedOutbox("mle_1", "текст", "spam");
|
||||
|
||||
@@ -59,7 +59,7 @@ public sealed class RuntimeDepthsCollectorTests
|
||||
[Fact]
|
||||
public async Task CollectAsync_CollectsBudgetRatiosPerTenant()
|
||||
{
|
||||
var limits = new FakeTenantLimitStore();
|
||||
var limits = new TestTenantLimitStore();
|
||||
DateTimeOffset periodStart = DateTimeOffset.UtcNow;
|
||||
limits.Preload(TenantA, 100, TenantLimitPeriods.Month, periodStart, 80);
|
||||
limits.Preload(TenantB, 0, TenantLimitPeriods.Month, periodStart, 55);
|
||||
@@ -69,7 +69,7 @@ public sealed class RuntimeDepthsCollectorTests
|
||||
PipelineStores(TenantA, TenantB, TenantC),
|
||||
OutboxStores(TenantA, TenantB, TenantC),
|
||||
TenantRecords(TenantA, TenantB, TenantC),
|
||||
limits);
|
||||
limits.Store);
|
||||
|
||||
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
|
||||
|
||||
@@ -83,7 +83,7 @@ public sealed class RuntimeDepthsCollectorTests
|
||||
[Fact]
|
||||
public async Task CollectAsync_BudgetReadFailure_SkipsFailedTenant()
|
||||
{
|
||||
var limits = new FakeTenantLimitStore();
|
||||
var limits = new TestTenantLimitStore();
|
||||
limits.FailStateReads.Add(TenantA);
|
||||
limits.Preload(TenantB, 100, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 80);
|
||||
|
||||
@@ -91,7 +91,7 @@ public sealed class RuntimeDepthsCollectorTests
|
||||
PipelineStores(TenantA, TenantB),
|
||||
OutboxStores(TenantA, TenantB),
|
||||
TenantRecords(TenantA, TenantB),
|
||||
limits);
|
||||
limits.Store);
|
||||
|
||||
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
|
||||
|
||||
@@ -105,8 +105,8 @@ public sealed class RuntimeDepthsCollectorTests
|
||||
public async Task CollectAsync_NoTenants_ReturnsZeros()
|
||||
{
|
||||
RuntimeDepthsCollector collector = Build(
|
||||
new Dictionary<string, FakePipelineStore>(),
|
||||
new Dictionary<string, FakeMlLearningStore>(),
|
||||
new Dictionary<string, TestPipelineStore>(),
|
||||
new Dictionary<string, TestMlLearningStore>(),
|
||||
tenants: Array.Empty<TenantRecordDto>());
|
||||
|
||||
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
|
||||
@@ -123,11 +123,11 @@ public sealed class RuntimeDepthsCollectorTests
|
||||
Status = PipelineQueueStatuses.New,
|
||||
};
|
||||
|
||||
private static Dictionary<string, FakePipelineStore> PipelineStores(params Guid[] tenants)
|
||||
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakePipelineStore());
|
||||
private static Dictionary<string, TestPipelineStore> PipelineStores(params Guid[] tenants)
|
||||
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new TestPipelineStore());
|
||||
|
||||
private static Dictionary<string, FakeMlLearningStore> OutboxStores(params Guid[] tenants)
|
||||
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakeMlLearningStore());
|
||||
private static Dictionary<string, TestMlLearningStore> OutboxStores(params Guid[] tenants)
|
||||
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new TestMlLearningStore());
|
||||
|
||||
private static TenantRecordDto[] TenantRecords(params Guid[] tenants)
|
||||
=> tenants
|
||||
@@ -139,8 +139,8 @@ public sealed class RuntimeDepthsCollectorTests
|
||||
|
||||
// Собирает коллектор поверх tenant-scoped фейков (как реальные адаптеры по ITenantContext).
|
||||
private static RuntimeDepthsCollector Build(
|
||||
IReadOnlyDictionary<string, FakePipelineStore> pipelineByTenant,
|
||||
IReadOnlyDictionary<string, FakeMlLearningStore> outboxByTenant,
|
||||
IReadOnlyDictionary<string, TestPipelineStore> pipelineByTenant,
|
||||
IReadOnlyDictionary<string, TestMlLearningStore> outboxByTenant,
|
||||
IReadOnlyList<TenantRecordDto>? tenants = null,
|
||||
ITenantLimitStore? limitStore = null)
|
||||
{
|
||||
@@ -153,12 +153,12 @@ public sealed class RuntimeDepthsCollectorTests
|
||||
new TenantRecordDto(TenantB, "B", "active", DateTimeOffset.UtcNow),
|
||||
}).ToArray()).Repository);
|
||||
|
||||
services.AddScoped<IPipelineStore>(provider => pipelineByTenant[CurrentTenant(provider)]);
|
||||
services.AddScoped<IMlClient>(_ => new FakeMlClient());
|
||||
services.AddScoped<IPipelineStore>(provider => pipelineByTenant[CurrentTenant(provider)].Store);
|
||||
services.AddScoped<IMlClient>(_ => new TestMlClient().Client);
|
||||
services.AddScoped<PipelineIngestService>();
|
||||
services.AddScoped<PipelineProcessingService>();
|
||||
services.AddScoped<IMlLearningStore>(provider => outboxByTenant[CurrentTenant(provider)]);
|
||||
services.AddSingleton<ITenantLimitStore>(limitStore ?? new FakeTenantLimitStore());
|
||||
services.AddScoped<IMlLearningStore>(provider => outboxByTenant[CurrentTenant(provider)].Store);
|
||||
services.AddSingleton<ITenantLimitStore>(limitStore ?? new TestTenantLimitStore().Store);
|
||||
|
||||
ServiceProvider provider = services.BuildServiceProvider();
|
||||
return new RuntimeDepthsCollector(
|
||||
|
||||
@@ -43,7 +43,7 @@ public sealed class TgStatusServiceTests
|
||||
[Fact]
|
||||
public async Task GetAsync_GatewayUnavailable_ReturnsIdleFormWithKvAccountCountAndKeys()
|
||||
{
|
||||
(TgStatusService service, TestTelegramStore store, FakeSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create();
|
||||
(TgStatusService service, TestTelegramStore store, TestSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create();
|
||||
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true));
|
||||
store.Seed(Dialog("d_2", "Группа", "group", Monitor: true));
|
||||
store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false));
|
||||
@@ -69,7 +69,7 @@ public sealed class TgStatusServiceTests
|
||||
[Fact]
|
||||
public async Task GetAsync_ReadyGateway_ComposesLiveFieldsWithKvAccountMonitoredAndKeys()
|
||||
{
|
||||
(TgStatusService service, TestTelegramStore store, FakeSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create();
|
||||
(TgStatusService service, TestTelegramStore store, TestSettingsStore settings, TestTelegramGateway gateway, _, TestGlobalSettingsStore globalSettings) = Create();
|
||||
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true));
|
||||
settings.Preload(SettingsKeys.TgAccount, "\"@realuser\"");
|
||||
PreloadKeys(globalSettings, "123456", "abcdefghijklmnop");
|
||||
@@ -106,16 +106,16 @@ public sealed class TgStatusServiceTests
|
||||
// ─── Хелперы ────────────────────────────────────────────────────────────
|
||||
|
||||
// Собирает сервис на фейках (FakeTelegramGateway/Store/SettingsStore/GlobalSettingsStore + ISecretCipher).
|
||||
private static (TgStatusService Service, TestTelegramStore Store, FakeSettingsStore Settings, TestTelegramGateway Gateway, ISecretCipher Cipher, TestGlobalSettingsStore GlobalSettings) Create()
|
||||
private static (TgStatusService Service, TestTelegramStore Store, TestSettingsStore Settings, TestTelegramGateway Gateway, ISecretCipher Cipher, TestGlobalSettingsStore GlobalSettings) Create()
|
||||
{
|
||||
var store = new TestTelegramStore();
|
||||
var settings = new FakeSettingsStore();
|
||||
var settings = new TestSettingsStore();
|
||||
var gateway = new TestTelegramGateway();
|
||||
var cipher = TestCiphers.New();
|
||||
var globalSettings = new TestGlobalSettingsStore();
|
||||
var keys = new TelegramKeysService(globalSettings.Store, cipher);
|
||||
var dialogs = new DialogsService(store.Store, settings, gateway.Gateway, NullLogger<DialogsService>.Instance);
|
||||
var service = new TgStatusService(gateway.Gateway, dialogs, settings, keys);
|
||||
var dialogs = new DialogsService(store.Store, settings.Store, gateway.Gateway, NullLogger<DialogsService>.Instance);
|
||||
var service = new TgStatusService(gateway.Gateway, dialogs, settings.Store, keys);
|
||||
return (service, store, settings, gateway, cipher, globalSettings);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace Deal.Tests.Unit.Contracts;
|
||||
/// </summary>
|
||||
public sealed class BudgetedAiClassifierTests
|
||||
{
|
||||
// Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита FakeTenantLimitStore.
|
||||
// Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита TestTenantLimitStore.
|
||||
private const string TenantIdValue = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
// Guid того же тенанта — ключ строки лимита.
|
||||
@@ -128,7 +128,7 @@ public sealed class BudgetedAiClassifierTests
|
||||
Context ctx = Create(
|
||||
limits => limits.Preload(
|
||||
TenantGuid, budgetTokens: 0, TenantLimitPeriods.Month, PeriodStart, usedTokens: 0));
|
||||
var standaloneLocal = new LocalAiClassifier(new LocalFieldsParser(ctx.Settings));
|
||||
var standaloneLocal = new LocalAiClassifier(new LocalFieldsParser(ctx.Settings.Store));
|
||||
const string text = "Ищем Python-разработчика на проект в команду\nБюджет: до 2к$";
|
||||
|
||||
AiParsedCardDto result = await ctx.Decorator.ClassifyAsync(text, CancellationToken.None);
|
||||
@@ -152,24 +152,24 @@ public sealed class BudgetedAiClassifierTests
|
||||
private sealed record Context(
|
||||
IAiClassifier Decorator,
|
||||
TestAiClassifier Paid,
|
||||
FakeSettingsStore Settings);
|
||||
TestSettingsStore Settings);
|
||||
|
||||
// Собирает контекст: платный фейк + реальный Local-классификатор + фейк лимитов и tenant-контекст
|
||||
// (зеркало регистрации AddDealIntegrations при UseLocal=false).
|
||||
// configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет).
|
||||
// Возвращает: Контекст теста.
|
||||
private static Context Create(Action<FakeTenantLimitStore>? configure = null)
|
||||
private static Context Create(Action<TestTenantLimitStore>? configure = null)
|
||||
{
|
||||
var settings = new FakeSettingsStore();
|
||||
var limits = new FakeTenantLimitStore();
|
||||
var settings = new TestSettingsStore();
|
||||
var limits = new TestTenantLimitStore();
|
||||
configure?.Invoke(limits);
|
||||
ITenantContext tenantContext = new TenantContext();
|
||||
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
||||
var paid = new TestAiClassifier();
|
||||
IAiClassifier decorator = new BudgetedAiClassifier(
|
||||
paid.Classifier,
|
||||
new LocalAiClassifier(new LocalFieldsParser(settings)),
|
||||
limits,
|
||||
new LocalAiClassifier(new LocalFieldsParser(settings.Store)),
|
||||
limits.Store,
|
||||
tenantContext,
|
||||
NullLogger<BudgetedAiClassifier>.Instance);
|
||||
return new Context(decorator, paid, settings);
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace Deal.Tests.Unit.Contracts;
|
||||
/// </summary>
|
||||
public sealed class BudgetedAiToolsTests
|
||||
{
|
||||
// Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита FakeTenantLimitStore.
|
||||
// Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита TestTenantLimitStore.
|
||||
private const string TenantIdValue = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
// Guid того же тенанта — ключ строки лимита.
|
||||
@@ -139,16 +139,16 @@ public sealed class BudgetedAiToolsTests
|
||||
// AddDealIntegrations при UseLocal=false).
|
||||
// configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет).
|
||||
// Возвращает: Контекст теста.
|
||||
private static Context Create(Action<FakeTenantLimitStore>? configure = null)
|
||||
private static Context Create(Action<TestTenantLimitStore>? configure = null)
|
||||
{
|
||||
var limits = new FakeTenantLimitStore();
|
||||
var limits = new TestTenantLimitStore();
|
||||
configure?.Invoke(limits);
|
||||
ITenantContext tenantContext = new TenantContext();
|
||||
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
||||
var paid = TestAiTools.New();
|
||||
IAiTools decorator = new BudgetedAiTools(
|
||||
paid,
|
||||
limits,
|
||||
limits.Store,
|
||||
tenantContext,
|
||||
NullLogger<BudgetedAiTools>.Instance);
|
||||
return new Context(decorator, paid);
|
||||
|
||||
@@ -6,7 +6,6 @@ using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Services;
|
||||
using Deal.Tests.Unit.Modules.Cards;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
using Deal.Tests.Unit.Support;
|
||||
|
||||
@@ -109,7 +108,7 @@ public sealed class CardComposerTests
|
||||
[Fact]
|
||||
public async Task BuildAsync_BoardWithoutRules_PlacesCardIntoBoardWithEmptyMatchHits()
|
||||
{
|
||||
(CardComposer composer, FakeKanjStore store, _) = Create();
|
||||
(CardComposer composer, TestKanjStore store, _) = Create();
|
||||
store.SeedBoard(new ContainerDto { Id = "b_py" });
|
||||
|
||||
CardSnapshot snapshot = await composer.BuildAsync(
|
||||
@@ -125,7 +124,7 @@ public sealed class CardComposerTests
|
||||
[Fact]
|
||||
public async Task BuildAsync_BoardWithNonMatchingRules_FallsBackToInbox()
|
||||
{
|
||||
(CardComposer composer, FakeKanjStore store, _) = Create();
|
||||
(CardComposer composer, TestKanjStore store, _) = Create();
|
||||
store.SeedBoard(BoardWithRules("b_wpf", new[] { "wpf" }));
|
||||
|
||||
CardSnapshot snapshot = await composer.BuildAsync(
|
||||
@@ -141,7 +140,7 @@ public sealed class CardComposerTests
|
||||
[Fact]
|
||||
public async Task BuildAsync_BoardWithMatchingRules_PlacesCardAndComputesMatchHits()
|
||||
{
|
||||
(CardComposer composer, FakeKanjStore store, _) = Create();
|
||||
(CardComposer composer, TestKanjStore store, _) = Create();
|
||||
store.SeedBoard(BoardWithRules("b_py", new[] { "python" }));
|
||||
|
||||
CardSnapshot snapshot = await composer.BuildAsync(
|
||||
@@ -193,7 +192,7 @@ public sealed class CardComposerTests
|
||||
[Fact]
|
||||
public async Task BuildAsync_ConversionDisabled_LeavesConvFieldsEmpty()
|
||||
{
|
||||
(CardComposer composer, _, FakeSettingsStore settings) = Create();
|
||||
(CardComposer composer, _, TestSettingsStore settings) = Create();
|
||||
settings.Preload(SettingsKeys.ConversionOn, "false"); // budget_to_target L348–349
|
||||
|
||||
CardSnapshot snapshot = await composer.BuildAsync(
|
||||
@@ -232,11 +231,11 @@ public sealed class CardComposerTests
|
||||
|
||||
// Создаёт контекст теста: пустые хранилища (дефолты: конверсия включена, курсы — мок).
|
||||
// Возвращает: Кортеж (композитор, канбан-хранилище, KV-настройки).
|
||||
private static (CardComposer Composer, FakeKanjStore Store, FakeSettingsStore Settings) Create()
|
||||
private static (CardComposer Composer, TestKanjStore Store, TestSettingsStore Settings) Create()
|
||||
{
|
||||
var store = new FakeKanjStore();
|
||||
var settings = new FakeSettingsStore();
|
||||
return (new CardComposer(store, settings), store, settings);
|
||||
var store = new TestKanjStore();
|
||||
var settings = new TestSettingsStore();
|
||||
return (new CardComposer(store.Store, settings.Store), store, settings);
|
||||
}
|
||||
|
||||
// Разбор карточки со значениями по умолчанию (сценарий теста перекрывает нужные поля).
|
||||
|
||||
@@ -3,7 +3,6 @@ using Deal.Tests.Unit.Support;
|
||||
using Deal.Modules.Cards.Application.Sources;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Tests.Unit.Modules.Cards;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
|
||||
namespace Deal.Tests.Unit.Contracts;
|
||||
@@ -17,7 +16,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task ListCards_NoCol_ReturnsAllOrderedByReceivedAtDesc()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("l_1", "inbox", receivedAtMs: 100));
|
||||
store.SeedCard(Card("l_2", "b_x", receivedAtMs: 300));
|
||||
|
||||
@@ -30,7 +29,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task ListCards_ByColumn_ReturnsOnlyColumnCards()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("l_1", "inbox"));
|
||||
store.SeedCard(Card("l_2", KanbanColumns.Trash));
|
||||
|
||||
@@ -43,7 +42,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task GetCard_Existing_ReturnsCard()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("l_1", "inbox", title: "Middle Python"));
|
||||
|
||||
CardDto? card = await service.GetCardAsync("l_1", CancellationToken.None);
|
||||
@@ -66,7 +65,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Move_ToBoardWithRules_UpdatesColumnAndComputesMatchHits()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
|
||||
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Middle Python-разработчик на бота, 1600$."));
|
||||
|
||||
@@ -84,7 +83,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Move_ToBoard_WritesJournalAndPush()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
|
||||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||||
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
|
||||
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
|
||||
|
||||
@@ -105,7 +104,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Move_ToInbox_WritesJournalWithoutPush()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
|
||||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||||
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
|
||||
store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", prevCol: "inbox"));
|
||||
|
||||
@@ -124,7 +123,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Move_ToUnknownBoard_Returns400Text()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
|
||||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||||
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
|
||||
|
||||
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_ghost", CancellationToken.None);
|
||||
@@ -148,7 +147,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Move_CardMissing_ReturnsNullLead()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
|
||||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||||
store.SeedBoard(Board("b_py"));
|
||||
|
||||
CardResultDto result = await service.MoveDashboardCardAsync("l_ghost", "b_py", CancellationToken.None);
|
||||
@@ -162,7 +161,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Move_SameColumn_NoOpWithoutJournalAndPush()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
|
||||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||||
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
|
||||
store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", isNew: true));
|
||||
|
||||
@@ -179,7 +178,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Move_ToBoardWithoutRules_MatchHitsEmptyButPushSent()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
|
||||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||||
store.SeedBoard(Board("b_free")); // правил нет
|
||||
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Любой текст"));
|
||||
|
||||
@@ -195,7 +194,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Move_FromBoardToBoard_PrevColIsOldBoardAndPushNewLabel()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
|
||||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||||
store.SeedBoard(Board("b_java", Rules(stack: new[] { "java" })));
|
||||
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
|
||||
store.SeedCard(Card("l_1", "b_java", sourceMsg: "Нужен Java-разработчик", prevCol: "inbox"));
|
||||
@@ -214,7 +213,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Move_EmptySourceText_UsesTitleForMatchHitsAndPush()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
|
||||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||||
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
|
||||
store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", sourceMsg: " "));
|
||||
|
||||
@@ -228,7 +227,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Move_EmptySourceAndTitle_JournalWithoutPush()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
|
||||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||||
store.SeedBoard(Board("b_py"));
|
||||
store.SeedCard(Card("l_1", "inbox"));
|
||||
|
||||
@@ -241,7 +240,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Move_ToBoardWithGradeAndBudget_HitsIncludeGradeWordAndBudget()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedBoard(Board("b_mid", Rules(
|
||||
grade: new[] { "middle" },
|
||||
budget: new BudgetRangeDto(1000, 3000, "USD"))));
|
||||
@@ -263,7 +262,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Move_FromArchive_Returns400AndWritesNothing()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
|
||||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||||
store.SeedBoard(Board("b_py"));
|
||||
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая вакансия", prevCol: "b_py"));
|
||||
|
||||
@@ -279,7 +278,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Move_FromTrash_Returns400AndWritesNothing()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
|
||||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||||
store.SeedBoard(Board("b_py"));
|
||||
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Спам-текст", prevCol: "b_py"));
|
||||
|
||||
@@ -296,7 +295,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Trash_FromInbox_MovesToTrashWithJournalAndSpamPush()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
|
||||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||||
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Предлагаю услуги продвижения", isNew: true));
|
||||
|
||||
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
|
||||
@@ -318,7 +317,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Trash_AlreadyInTrash_NoOpWithoutJournalAndPush()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
|
||||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||||
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст"));
|
||||
|
||||
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
|
||||
@@ -331,7 +330,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Trash_FromArchive_WritesJournalWithoutPush()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
|
||||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||||
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py"));
|
||||
|
||||
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
|
||||
@@ -346,7 +345,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Trash_CardMissing_ReturnsNull()
|
||||
{
|
||||
(CardsService service, _, _, FakeMlClient ml) = Create();
|
||||
(CardsService service, _, _, TestMlClient ml) = Create();
|
||||
|
||||
CardDto? result = await service.TrashCardAsync("l_ghost", CancellationToken.None);
|
||||
|
||||
@@ -358,7 +357,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Restore_FromTrash_ToPrevColBoard_UnlearnsSpam()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
|
||||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||||
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
|
||||
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Нужен Python-разработчик", prevCol: "b_py"));
|
||||
|
||||
@@ -383,7 +382,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Restore_FromArchive_NoPush()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
|
||||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||||
store.SeedBoard(Board("b_py"));
|
||||
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py"));
|
||||
|
||||
@@ -399,7 +398,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Restore_FromTrash_PrevColDeletedBoard_FallsBackToInbox()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
|
||||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||||
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст", prevCol: "b_gone"));
|
||||
|
||||
string? back = await service.RestoreCardAsync("l_1", CancellationToken.None);
|
||||
@@ -414,7 +413,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Restore_FromTrash_PrevColInbox_ReturnsInbox()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст", prevCol: "inbox"));
|
||||
|
||||
string? back = await service.RestoreCardAsync("l_1", CancellationToken.None);
|
||||
@@ -439,7 +438,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task DeleteForever_RemovesCardAndComments_KeepsJournal()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("l_1", KanbanColumns.Trash,
|
||||
sourceMsg: "Нужен Python-разработчик",
|
||||
comments: [new CardCommentDto("cm_1", "Вы", "Перезвонить", "5 мин")]));
|
||||
@@ -454,7 +453,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task DeleteForever_JournalRowsSurviveCardDeletion()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
|
||||
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
|
||||
|
||||
@@ -480,7 +479,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task ClearCol_Trash_ReturnsClearedCount()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("l_1", KanbanColumns.Trash));
|
||||
store.SeedCard(Card("l_2", KanbanColumns.Trash));
|
||||
store.SeedCard(Card("l_3", KanbanColumns.Archive));
|
||||
@@ -507,7 +506,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task ClearCol_Board_Returns400Text()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedBoard(Board("b_py"));
|
||||
store.SeedCard(Card("l_1", "b_py"));
|
||||
|
||||
@@ -531,7 +530,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task MarkSeen_ById_OnlyThatCard()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("l_1", "inbox", isNew: true));
|
||||
store.SeedCard(Card("l_2", "inbox", isNew: true));
|
||||
|
||||
@@ -544,7 +543,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task MarkSeen_ByCol_OnlyColumnCards()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("l_1", "inbox", isNew: true));
|
||||
store.SeedCard(Card("l_2", "inbox", isNew: true));
|
||||
store.SeedCard(Card("l_3", "b_py", isNew: true));
|
||||
@@ -558,7 +557,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task MarkSeen_All_WithoutParameters()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("l_1", "inbox", isNew: true));
|
||||
store.SeedCard(Card("l_2", KanbanColumns.Trash, isNew: true));
|
||||
|
||||
@@ -571,7 +570,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task AddComment_EmptyOrWhitespaceText_Returns400Text()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("l_1", "inbox"));
|
||||
|
||||
AddCommentResultDto empty = await service.AddCommentAsync("l_1", " ", CancellationToken.None);
|
||||
@@ -585,7 +584,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task AddComment_Valid_AppendsTrimmedCommentWithJournal()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("l_1", "inbox"));
|
||||
|
||||
AddCommentResultDto result = await service.AddCommentAsync("l_1", " Перезвонить завтра ", CancellationToken.None);
|
||||
@@ -618,7 +617,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Counts_ColumnsNewAndMlStats()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, FakeMlClient ml) = Create();
|
||||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||||
store.SeedCard(Card("l_1", "inbox", isNew: true));
|
||||
store.SeedCard(Card("l_2", "inbox"));
|
||||
store.SeedCard(Card("l_3", "b_py"));
|
||||
@@ -652,7 +651,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Search_QueryShorterThanTwoChars_ReturnsEmptyAndDoesNotCallStore()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("l_1", "inbox", title: "Python-разработчик"));
|
||||
|
||||
IReadOnlyList<CardDto> one = await service.SearchCardsAsync("p", CancellationToken.None);
|
||||
@@ -666,7 +665,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Search_DelegatesToStoreWithQueryAndLimit()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300));
|
||||
|
||||
IReadOnlyList<CardDto> result = await service.SearchCardsAsync(" pYtHoN ", CancellationToken.None);
|
||||
@@ -681,7 +680,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Search_FindsByTitleSummaryContactSourceText_OrderedByReceivedAtDesc()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("l_title", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300));
|
||||
store.SeedCard(Card("l_summary", "b_py", summary: "проект на python", receivedAtMs: 200));
|
||||
store.SeedCard(Card("l_contact", "inbox", contact: "@python_dev", receivedAtMs: 100));
|
||||
@@ -696,7 +695,7 @@ public sealed class CardsServiceTests
|
||||
[Fact]
|
||||
public async Task Search_LimitIsTwelve()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
for (int i = 0; i < 15; i++)
|
||||
{
|
||||
store.SeedCard(Card($"l_{i:00}", "inbox", title: $"python {i}", receivedAtMs: i));
|
||||
@@ -711,12 +710,12 @@ public sealed class CardsServiceTests
|
||||
|
||||
// ─── Хелперы ──────────────────────────────────────────────────────────
|
||||
|
||||
private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings, FakeMlClient Ml) Create()
|
||||
private static (CardsService Service, TestKanjStore Store, TestSettingsStore Settings, TestMlClient Ml) Create()
|
||||
{
|
||||
var store = new FakeKanjStore();
|
||||
var settings = new FakeSettingsStore();
|
||||
var ml = new FakeMlClient();
|
||||
return (new CardsService(store, settings, ml, new TestFileStorage().Storage), store, settings, ml);
|
||||
var store = new TestKanjStore();
|
||||
var settings = new TestSettingsStore();
|
||||
var ml = new TestMlClient();
|
||||
return (new CardsService(store.Store, settings.Store, ml.Client, new TestFileStorage().Storage), store, settings, ml);
|
||||
}
|
||||
|
||||
// Доска с правилами (ContainerRulesDto) либо без них.
|
||||
|
||||
@@ -38,7 +38,7 @@ public sealed class DialogsServiceTests
|
||||
[Fact]
|
||||
public async Task Sync_AutoMonitorNewFalse_AddsNewDialogsUnmonitored()
|
||||
{
|
||||
(DialogsService service, TestTelegramStore store, FakeSettingsStore settings, _) = Create();
|
||||
(DialogsService service, TestTelegramStore store, TestSettingsStore settings, _) = Create();
|
||||
settings.Preload(SettingsKeys.AutoMonitorNew, "false");
|
||||
|
||||
int count = await service.SyncFromTelegramAsync(Catalog(Entry("d_1", "Канал", "channel")), CancellationToken.None);
|
||||
@@ -497,12 +497,12 @@ public sealed class DialogsServiceTests
|
||||
// ─── Хелперы ────────────────────────────────────────────────────────────
|
||||
|
||||
// Собирает сервис на фейках (TestTelegramStore — семантика адаптера; настройки/гейт чистые).
|
||||
private static (DialogsService Service, TestTelegramStore Store, FakeSettingsStore Settings, TestTelegramGateway Gateway) Create()
|
||||
private static (DialogsService Service, TestTelegramStore Store, TestSettingsStore Settings, TestTelegramGateway Gateway) Create()
|
||||
{
|
||||
var store = new TestTelegramStore();
|
||||
var settings = new FakeSettingsStore();
|
||||
var settings = new TestSettingsStore();
|
||||
var gateway = new TestTelegramGateway();
|
||||
return (new DialogsService(store.Store, settings, gateway.Gateway, NullLogger<DialogsService>.Instance), store, settings, gateway);
|
||||
return (new DialogsService(store.Store, settings.Store, gateway.Gateway, NullLogger<DialogsService>.Instance), store, settings, gateway);
|
||||
}
|
||||
|
||||
// Строка каталога минимально (id/имя/kind + флаги/хвост каталога).
|
||||
|
||||
@@ -30,7 +30,7 @@ public sealed class DiscoveryEvaluatorTests
|
||||
[Fact]
|
||||
public async Task EvaluateMessage_MlSpam_NotFit()
|
||||
{
|
||||
(DiscoveryEvaluator evaluator, _, FakeMlClient ml, _) = Create(aiEnabled: false, mlEnabled: true);
|
||||
(DiscoveryEvaluator evaluator, _, TestMlClient ml, _) = Create(aiEnabled: false, mlEnabled: true);
|
||||
ml.Predict = new MlPredictResultDto(
|
||||
Take: true, Label: "spam", Scores: new Dictionary<string, double>(), Hits: 3, Ready: true, Margin: 0.9,
|
||||
Terms: Array.Empty<string>(), Type: null);
|
||||
@@ -46,7 +46,7 @@ public sealed class DiscoveryEvaluatorTests
|
||||
[Fact]
|
||||
public async Task EvaluateMessage_AiFits_ReturnsAiVerdict()
|
||||
{
|
||||
(DiscoveryEvaluator evaluator, _, FakeMlClient ml, IAiTools ai) = Create(aiEnabled: true, mlEnabled: false);
|
||||
(DiscoveryEvaluator evaluator, _, TestMlClient ml, IAiTools ai) = Create(aiEnabled: true, mlEnabled: false);
|
||||
ai.ReturnsFit(new AiEvaluateFitResultDto(Fit: true, "тема совпадает"));
|
||||
|
||||
DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync(
|
||||
@@ -168,14 +168,14 @@ public sealed class DiscoveryEvaluatorTests
|
||||
=> new(id, text, DateMs: 1_700_000_000_000 + id, topic, TopicTitle: null);
|
||||
|
||||
// Создаёт оценщик на фейках: флаги веток выставляются сценарием.
|
||||
private static (DiscoveryEvaluator Evaluator, FakeSettingsStore Settings, FakeMlClient Ml, IAiTools Ai) Create(bool aiEnabled, bool mlEnabled)
|
||||
private static (DiscoveryEvaluator Evaluator, TestSettingsStore Settings, TestMlClient Ml, IAiTools Ai) Create(bool aiEnabled, bool mlEnabled)
|
||||
{
|
||||
var settings = new FakeSettingsStore();
|
||||
var settings = new TestSettingsStore();
|
||||
settings.Preload(SettingsKeys.AiEnabled, aiEnabled ? "true" : "false");
|
||||
settings.Preload(SettingsKeys.MlEnabled, mlEnabled ? "true" : "false");
|
||||
var ml = new FakeMlClient();
|
||||
var ml = new TestMlClient();
|
||||
var ai = TestAiTools.New();
|
||||
return (new DiscoveryEvaluator(settings, ml, ai), settings, ml, ai);
|
||||
return (new DiscoveryEvaluator(settings.Store, ml.Client, ai), settings, ml, ai);
|
||||
}
|
||||
|
||||
// Задача как строка DiscTasks (адаптер-форма; нужны только keywords/description/threshold).
|
||||
|
||||
@@ -419,7 +419,7 @@ public sealed class DiscoveryWorkerServiceTests
|
||||
// Контекст теста: воркер на фейках + ссылки для проверок состояния.
|
||||
private sealed record Fixture(
|
||||
TestDiscoveryStore Store,
|
||||
FakeSettingsStore Settings,
|
||||
TestSettingsStore Settings,
|
||||
TestDiscoveryGateway Gateway,
|
||||
IDiscoveryPacer Pacer,
|
||||
DiscoveryWorkerService Worker,
|
||||
@@ -429,21 +429,21 @@ public sealed class DiscoveryWorkerServiceTests
|
||||
private static Fixture Create()
|
||||
{
|
||||
var store = new TestDiscoveryStore();
|
||||
var settings = new FakeSettingsStore();
|
||||
var settings = new TestSettingsStore();
|
||||
settings.Preload(SettingsKeys.MlEnabled, "false");
|
||||
settings.Preload(SettingsKeys.AiEnabled, "false");
|
||||
var ml = new FakeMlClient();
|
||||
var ml = new TestMlClient();
|
||||
var ai = TestAiTools.New();
|
||||
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 planGuard = new DiscoveryPlanGuard(store.Store, settings.Store);
|
||||
var tasks = new DiscoveryTasksService(store.Store, planGuard, settings.Store);
|
||||
var banGuard = new DiscoveryBanGuard(store.Store, settings.Store);
|
||||
var pacer = Substitute.For<IDiscoveryPacer>();
|
||||
var gateway = new TestDiscoveryGateway();
|
||||
var worker = new DiscoveryWorkerService(
|
||||
store.Store, tasks, candidates, log, new DiscoveryEvaluator(settings, ml, ai), banGuard, pacer, gateway.Gateway);
|
||||
store.Store, tasks, candidates, log, new DiscoveryEvaluator(settings.Store, ml.Client, ai), banGuard, pacer, gateway.Gateway);
|
||||
return new Fixture(store, settings, gateway, pacer, worker, tasks);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,85 +0,0 @@
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
|
||||
namespace Deal.Tests.Unit.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory реализация <see cref="IFileStorage"/> для unit-тестов файлов карточки
|
||||
/// </summary>
|
||||
public sealed class FakeFileStorage : IFileStorage
|
||||
{
|
||||
private readonly Dictionary<string, byte[]> _objects = new(StringComparer.Ordinal);
|
||||
private readonly Dictionary<string, string> _contentTypes = new(StringComparer.Ordinal);
|
||||
private readonly List<string> _deletedKeys = [];
|
||||
|
||||
/// <summary>
|
||||
/// objectKey всех объектов, сохранённых на данный момент
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> StoredObjectKeys => _objects.Keys.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// objectKey всех удалений в порядке вызовов DeleteAsync
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> DeletedKeys => _deletedKeys.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Содержимое сохранённого объекта по ключу либо null — объекта нет
|
||||
/// </summary>
|
||||
/// <param name="objectKey">Ключ объекта (opaque).</param>
|
||||
public byte[]? ContentOf(string objectKey)
|
||||
{
|
||||
return _objects.TryGetValue(objectKey, out byte[]? content) ? content : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<string> PutAsync(
|
||||
string objectKey,
|
||||
Stream content,
|
||||
string contentType,
|
||||
CancellationToken ct)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(content);
|
||||
|
||||
using MemoryStream buffer = new();
|
||||
if (content.CanSeek && content.Position != 0)
|
||||
{
|
||||
content.Position = 0; // контракт порта: Put читает всё содержимое с позиции 0 (Ruling T6)
|
||||
}
|
||||
|
||||
content.CopyTo(buffer);
|
||||
_objects[objectKey] = buffer.ToArray();
|
||||
_contentTypes[objectKey] = contentType;
|
||||
return Task.FromResult(objectKey);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<Stream?> GetAsync(string objectKey, CancellationToken ct)
|
||||
{
|
||||
if (!_objects.TryGetValue(objectKey, out byte[]? content))
|
||||
{
|
||||
return Task.FromResult<Stream?>(null);
|
||||
}
|
||||
|
||||
return Task.FromResult<Stream?>(new MemoryStream(content));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<FileMeta?> StatAsync(string objectKey, CancellationToken ct)
|
||||
{
|
||||
if (!_objects.TryGetValue(objectKey, out byte[]? content))
|
||||
{
|
||||
return Task.FromResult<FileMeta?>(null);
|
||||
}
|
||||
|
||||
string contentType = _contentTypes.TryGetValue(objectKey, out string? stored) ? stored : string.Empty;
|
||||
return Task.FromResult<FileMeta?>(new FileMeta(objectKey, content.Length, contentType));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteAsync(string objectKey, CancellationToken ct)
|
||||
{
|
||||
_deletedKeys.Add(objectKey);
|
||||
_objects.Remove(objectKey);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -39,9 +39,9 @@ public sealed class GrpcAiToolsTests
|
||||
reply.Keywords.Add("python разработка");
|
||||
reply.Keywords.Add("telegram каналы");
|
||||
service.GenerateKeywordsReply = reply;
|
||||
FakeSettingsStore settings = new();
|
||||
TestSettingsStore settings = new();
|
||||
ISecretCipher cipher = TestCiphers.New();
|
||||
FakeTenantLimitStore limits = new();
|
||||
TestTenantLimitStore limits = new();
|
||||
|
||||
IAiTools tools = CreateTools(port, settings, cipher, limits);
|
||||
AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("Бэкенд-разработка на Python", CancellationToken.None);
|
||||
@@ -66,7 +66,7 @@ public sealed class GrpcAiToolsTests
|
||||
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
|
||||
{
|
||||
service.KeywordsUnavailable = true;
|
||||
IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New());
|
||||
IAiTools tools = CreateTools(port, new TestSettingsStore(), TestCiphers.New());
|
||||
|
||||
AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("описание", CancellationToken.None);
|
||||
|
||||
@@ -88,8 +88,8 @@ public sealed class GrpcAiToolsTests
|
||||
Reason = "другая сфера",
|
||||
Usage = new Usage { Prompt = 200, Completion = 10, Total = 210 },
|
||||
};
|
||||
FakeTenantLimitStore limits = new();
|
||||
IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New(), limits);
|
||||
TestTenantLimitStore limits = new();
|
||||
IAiTools tools = CreateTools(port, new TestSettingsStore(), TestCiphers.New(), limits);
|
||||
|
||||
AiEvaluateFitResultDto result = await tools.EvaluateFitAsync(
|
||||
"Ищу дизайнера для лендинга", "Разработка сайтов на Python", new[] { "python", "бэкенд" }, CancellationToken.None);
|
||||
@@ -113,7 +113,7 @@ public sealed class GrpcAiToolsTests
|
||||
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
|
||||
{
|
||||
service.FitUnavailable = true;
|
||||
IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New());
|
||||
IAiTools tools = CreateTools(port, new TestSettingsStore(), TestCiphers.New());
|
||||
|
||||
await Assert.ThrowsAsync<AiUnavailableException>(
|
||||
() => tools.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None));
|
||||
@@ -128,18 +128,19 @@ public sealed class GrpcAiToolsTests
|
||||
// Возвращает: Экземпляр GrpcAiTools.
|
||||
private static GrpcAiTools CreateTools(
|
||||
int port,
|
||||
FakeSettingsStore settings,
|
||||
TestSettingsStore settings,
|
||||
ISecretCipher cipher,
|
||||
FakeTenantLimitStore? limits = null)
|
||||
TestTenantLimitStore? limits = null)
|
||||
{
|
||||
limits ??= new TestTenantLimitStore();
|
||||
ITenantContext tenantContext = new TenantContext();
|
||||
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
||||
var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
|
||||
return new GrpcAiTools(
|
||||
tenantContext,
|
||||
connection,
|
||||
new AiProviderConfigBuilder(settings, cipher),
|
||||
new TokenUsageRecorder(settings, limits ?? new FakeTenantLimitStore(), tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
|
||||
new AiProviderConfigBuilder(settings.Store, cipher),
|
||||
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
|
||||
NullLogger<GrpcAiTools>.Instance);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -110,10 +110,10 @@ public sealed class GrpcMlClientTests
|
||||
Eval = new ModelEval { Count = 5, Correct = 4, Accuracy = 0.8 },
|
||||
};
|
||||
|
||||
var settings = new FakeSettingsStore();
|
||||
var settings = new TestSettingsStore();
|
||||
settings.Preload(SettingsKeys.MlDecisions, "7");
|
||||
settings.Preload(SettingsKeys.AiDecisions, "3");
|
||||
var learning = new FakeMlLearningStore { LearningCount = 9 };
|
||||
var learning = new TestMlLearningStore { LearningCount = 9 };
|
||||
|
||||
IMlClient client = CreateClient(port, settings, learning);
|
||||
MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None);
|
||||
@@ -195,7 +195,7 @@ public sealed class GrpcMlClientTests
|
||||
{
|
||||
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
||||
{
|
||||
var learning = new FakeMlLearningStore();
|
||||
var learning = new TestMlLearningStore();
|
||||
learning.SeedOutbox("mle_1", "текст 1", "b_a", 1.0);
|
||||
learning.SeedOutbox("mle_2", "текст 2", "spam", 1.0);
|
||||
IMlClient client = CreateClient(port, learning: learning);
|
||||
@@ -206,7 +206,7 @@ public sealed class GrpcMlClientTests
|
||||
|
||||
Assert.True(reset.Ok);
|
||||
Assert.Null(reset.Error);
|
||||
Assert.Equal(0, await learning.CountOutboxAsync(CancellationToken.None)); // очередь очищена
|
||||
Assert.Equal(0, await learning.Store.CountOutboxAsync(CancellationToken.None)); // очередь очищена
|
||||
Assert.Equal(1, service.ResetCalls);
|
||||
|
||||
_ = await client.StatusAsync(CancellationToken.None);
|
||||
@@ -220,7 +220,7 @@ public sealed class GrpcMlClientTests
|
||||
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
||||
{
|
||||
service.ResetReply = new ResetReply { Ok = false, Error = "не удалось пересоздать файл модели" };
|
||||
var learning = new FakeMlLearningStore();
|
||||
var learning = new TestMlLearningStore();
|
||||
learning.SeedOutbox("mle_1", "текст", "b_a", 1.0);
|
||||
|
||||
IMlClient client = CreateClient(port, learning: learning);
|
||||
@@ -228,7 +228,7 @@ public sealed class GrpcMlClientTests
|
||||
|
||||
Assert.False(reset.Ok);
|
||||
Assert.Equal("не удалось пересоздать файл модели", reset.Error);
|
||||
Assert.Equal(1, await learning.CountOutboxAsync(CancellationToken.None));
|
||||
Assert.Equal(1, await learning.Store.CountOutboxAsync(CancellationToken.None));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -238,7 +238,7 @@ public sealed class GrpcMlClientTests
|
||||
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
||||
{
|
||||
service.ResetUnavailable = true;
|
||||
var learning = new FakeMlLearningStore();
|
||||
var learning = new TestMlLearningStore();
|
||||
learning.SeedOutbox("mle_1", "текст", "spam", 1.0);
|
||||
|
||||
IMlClient client = CreateClient(port, learning: learning);
|
||||
@@ -246,7 +246,7 @@ public sealed class GrpcMlClientTests
|
||||
|
||||
Assert.False(reset.Ok);
|
||||
Assert.Equal("ML-сервис недоступен", reset.Error);
|
||||
Assert.Equal(1, await learning.CountOutboxAsync(CancellationToken.None));
|
||||
Assert.Equal(1, await learning.Store.CountOutboxAsync(CancellationToken.None));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -256,7 +256,7 @@ public sealed class GrpcMlClientTests
|
||||
{
|
||||
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
||||
{
|
||||
var learning = new FakeMlLearningStore();
|
||||
var learning = new TestMlLearningStore();
|
||||
IMlClient client = CreateClient(port, learning: learning);
|
||||
|
||||
await client.PushAsync(" нужен python ", "b_junior", 1.0, CancellationToken.None);
|
||||
@@ -309,22 +309,24 @@ public sealed class GrpcMlClientTests
|
||||
// Возвращает: Экземпляр GrpcMlClient в tenant-контексте теста.
|
||||
private static GrpcMlClient CreateClient(
|
||||
int port,
|
||||
FakeSettingsStore? settings = null,
|
||||
FakeMlLearningStore? learning = null,
|
||||
TestSettingsStore? settings = null,
|
||||
TestMlLearningStore? learning = null,
|
||||
MlStatusCache? cache = null)
|
||||
{
|
||||
settings ??= new TestSettingsStore();
|
||||
learning ??= new TestMlLearningStore();
|
||||
ITenantContext tenantContext = new TenantContext();
|
||||
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
||||
var options = new MlServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" };
|
||||
return new GrpcMlClient(
|
||||
tenantContext,
|
||||
settings ?? new FakeSettingsStore(),
|
||||
learning ?? new FakeMlLearningStore(),
|
||||
settings.Store,
|
||||
learning.Store,
|
||||
new MlGrpcConnection(options),
|
||||
cache ?? new MlStatusCache(),
|
||||
new TokenUsageRecorder(
|
||||
settings ?? new FakeSettingsStore(),
|
||||
new FakeTenantLimitStore(),
|
||||
settings.Store,
|
||||
new TestTenantLimitStore().Store,
|
||||
tenantContext,
|
||||
new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
|
||||
NullLogger<GrpcMlClient>.Instance);
|
||||
|
||||
@@ -13,7 +13,6 @@ using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Deal.Tests.Unit.Modules.Cards;
|
||||
using Deal.Tests.Unit.Modules.Kanban;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
using Deal.Tests.Unit.Modules.Tenants;
|
||||
@@ -144,11 +143,11 @@ public sealed class IntegrationsDiTests
|
||||
ITenantContext tenantContext = new TenantContext();
|
||||
tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N")));
|
||||
services.AddSingleton<ITenantContext>(tenantContext);
|
||||
services.AddScoped<ISettingsStore>(_ => new FakeSettingsStore());
|
||||
services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store);
|
||||
services.AddScoped<ISecretCipher>(_ => TestCiphers.New());
|
||||
services.AddScoped<ICardStore>(_ => new FakeKanjStore());
|
||||
services.AddScoped<IMlLearningStore>(_ => new FakeMlLearningStore());
|
||||
services.AddScoped<ITenantLimitStore>(_ => new FakeTenantLimitStore());
|
||||
services.AddScoped<ICardStore>(_ => new TestKanjStore().Store);
|
||||
services.AddScoped<IMlLearningStore>(_ => new TestMlLearningStore().Store);
|
||||
services.AddScoped<ITenantLimitStore>(_ => new TestTenantLimitStore().Store);
|
||||
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
|
||||
services.AddScoped<LocalFieldsParser>();
|
||||
services.AddScoped<AiClassifyContextBuilder>();
|
||||
|
||||
@@ -2,6 +2,7 @@ using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Infrastructure.Integrations.Services;
|
||||
using Deal.Modules.Pipeline.Application.Parse;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
|
||||
namespace Deal.Tests.Unit.Contracts;
|
||||
@@ -13,10 +14,10 @@ public sealed class LocalAiClassifierTests
|
||||
{
|
||||
// Создаёт контекст теста: пустое KV-хранилище (дефолты маркеров) + адаптер.
|
||||
// Возвращает: Кортеж (KV-настройки, классификатор).
|
||||
private static (FakeSettingsStore Settings, LocalAiClassifier Classifier) CreateContext()
|
||||
private static (TestSettingsStore Settings, LocalAiClassifier Classifier) CreateContext()
|
||||
{
|
||||
var settings = new FakeSettingsStore();
|
||||
var classifier = new LocalAiClassifier(new LocalFieldsParser(settings));
|
||||
var settings = new TestSettingsStore();
|
||||
var classifier = new LocalAiClassifier(new LocalFieldsParser(settings.Store));
|
||||
return (settings, classifier);
|
||||
}
|
||||
|
||||
@@ -140,7 +141,7 @@ public sealed class LocalAiClassifierTests
|
||||
[Fact]
|
||||
public async Task Classify_CustomHireMarkersOverride_AreUsed()
|
||||
{
|
||||
(FakeSettingsStore settings, LocalAiClassifier classifier) = CreateContext();
|
||||
(TestSettingsStore settings, LocalAiClassifier classifier) = CreateContext();
|
||||
// Дефолтные маркеры заменены: «вакансия» больше не маркер найма, свой — маркер.
|
||||
settings.Preload(SettingsKeys.HireMarkers, "[\"нанимаем джунов\"]");
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
using Deal.Tests.Unit.Modules.Cards;
|
||||
using Deal.Tests.Unit.Modules.Kanban;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
using Deal.Tests.Unit.Support;
|
||||
@@ -18,9 +17,9 @@ public sealed class PipelineCardWriterTests
|
||||
[Fact]
|
||||
public async Task CreateCard_CreatesInboxCardAndLinksDedupClaim()
|
||||
{
|
||||
(PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create();
|
||||
(PipelineCardWriter writer, TestKanjStore store, TestPipelineStore pipelineStore) = Create();
|
||||
const string hash = "abcdef0123456789abcdef0123456789abcdef01";
|
||||
await pipelineStore.ClaimAsync(hash, CancellationToken.None); // заявка воркера (LeadId=null, Ruling 8)
|
||||
await pipelineStore.Store.ClaimAsync(hash, CancellationToken.None); // заявка воркера (LeadId=null, Ruling 8)
|
||||
AiParsedCardDto parsed = Parsed(
|
||||
title: "Python-разработчик на бота",
|
||||
budget: new AiBudgetDto(2000, 2000, "USD"),
|
||||
@@ -38,16 +37,16 @@ public sealed class PipelineCardWriterTests
|
||||
Assert.Equal(new CardBudgetDto(2000, 2000, "USD"), card.Budget);
|
||||
|
||||
Assert.Equal(card.Id, pipelineStore.DedupLeadId(hash));
|
||||
Assert.True(await pipelineStore.ExistsAsync(hash, CancellationToken.None));
|
||||
Assert.True(await pipelineStore.Store.ExistsAsync(hash, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateCard_AssignedBoardAccepted_WritesCardIntoBoardColumn()
|
||||
{
|
||||
(PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create();
|
||||
(PipelineCardWriter writer, TestKanjStore store, TestPipelineStore pipelineStore) = Create();
|
||||
store.SeedBoard(new ContainerDto { Id = "b_py" });
|
||||
const string hash = "abcdef0123456789abcdef0123456789abcdef01";
|
||||
await pipelineStore.ClaimAsync(hash, CancellationToken.None);
|
||||
await pipelineStore.Store.ClaimAsync(hash, CancellationToken.None);
|
||||
|
||||
CardDto card = await writer.CreateCardAsync(
|
||||
Parsed(title: "Python-разработчик", board: "b_py"),
|
||||
@@ -66,10 +65,10 @@ public sealed class PipelineCardWriterTests
|
||||
[Fact]
|
||||
public async Task CreateCard_AddCardFails_ThrowsAndDoesNotLinkDedup()
|
||||
{
|
||||
(PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create();
|
||||
(PipelineCardWriter writer, TestKanjStore store, TestPipelineStore pipelineStore) = Create();
|
||||
store.FailAddCard = true; // сбой адаптера записи (например, недоступна БД тенанта)
|
||||
const string hash = "abcdef0123456789abcdef0123456789abcdef01";
|
||||
await pipelineStore.ClaimAsync(hash, CancellationToken.None);
|
||||
await pipelineStore.Store.ClaimAsync(hash, CancellationToken.None);
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => writer.CreateCardAsync(Parsed(title: "Python-разработчик"), Message("Текст"), hash, CancellationToken.None));
|
||||
@@ -77,19 +76,19 @@ public sealed class PipelineCardWriterTests
|
||||
// Карточка не создана → связывать нечего: заявка дедупа осталась несвязанной (LeadId=null).
|
||||
Assert.Empty(store.CardDtos);
|
||||
Assert.Null(pipelineStore.DedupLeadId(hash));
|
||||
Assert.True(await pipelineStore.ExistsAsync(hash, CancellationToken.None)); // заявка на месте
|
||||
Assert.True(await pipelineStore.Store.ExistsAsync(hash, CancellationToken.None)); // заявка на месте
|
||||
}
|
||||
|
||||
// ─── Хелперы ──────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Создаёт контекст теста: фейки канбана, пайплайна и композитор поверх них.
|
||||
// Создаёт контекст теста: подставки канбана, пайплайна и композитор поверх них.
|
||||
// Возвращает: Кортеж (обёртка, канбан-хранилище, хранилище пайплайна).
|
||||
private static (PipelineCardWriter Writer, FakeKanjStore Store, FakePipelineStore PipelineStore) Create()
|
||||
private static (PipelineCardWriter Writer, TestKanjStore Store, TestPipelineStore PipelineStore) Create()
|
||||
{
|
||||
var store = new FakeKanjStore();
|
||||
var pipelineStore = new FakePipelineStore();
|
||||
var composer = new CardComposer(store, new FakeSettingsStore());
|
||||
return (new PipelineCardWriter(store, pipelineStore, composer), store, pipelineStore);
|
||||
var store = new TestKanjStore();
|
||||
var pipelineStore = new TestPipelineStore();
|
||||
var composer = new CardComposer(store.Store, new TestSettingsStore().Store);
|
||||
return (new PipelineCardWriter(store.Store, pipelineStore.Store, composer), store, pipelineStore);
|
||||
}
|
||||
|
||||
// Разбор карточки со значениями по умолчанию (сценарий теста перекрывает нужные поля).
|
||||
|
||||
@@ -15,7 +15,6 @@ using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Deal.Tests.Unit.Grpc;
|
||||
using Deal.Tests.Unit.Modules.Cards;
|
||||
using Deal.Tests.Unit.Modules.Kanban;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
using Deal.Tests.Unit.Modules.Tenants;
|
||||
@@ -34,7 +33,7 @@ public sealed class PipelineWorkerGrpcAiTests
|
||||
// Id тенанта сценария строкой (формат N) — metadata tenant-id вызовов ai-service.
|
||||
private const string TenantIdValue = "abcdefabcdefabcdefabcdefabcdefab";
|
||||
|
||||
// Guid того же тенанта — ключ строки лимита в FakeTenantLimitStore (списание usage).
|
||||
// Guid того же тенанта — ключ строки лимита в TestTenantLimitStore (списание usage).
|
||||
private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue);
|
||||
|
||||
[Fact]
|
||||
@@ -127,7 +126,7 @@ public sealed class PipelineWorkerGrpcAiTests
|
||||
Usage = new Usage { Prompt = 2000, Completion = 400, Total = 2400 },
|
||||
};
|
||||
|
||||
var limits = new FakeTenantLimitStore();
|
||||
var limits = new TestTenantLimitStore();
|
||||
limits.Preload(TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, usedTokens: 1000);
|
||||
Context ctx = CreateContext(port, limits, budgeted: true);
|
||||
ctx.PipelineStore.SeedQueue(QueueRowFiltered("p_1", "Нужен middle Python-разработчик в команду, оплата 2000$ в месяц"));
|
||||
@@ -154,49 +153,49 @@ public sealed class PipelineWorkerGrpcAiTests
|
||||
|
||||
private sealed record Context(
|
||||
PipelineWorkerService Worker,
|
||||
FakePipelineStore PipelineStore,
|
||||
FakeKanjStore KanjStore,
|
||||
FakeSettingsStore Settings,
|
||||
FakeTenantLimitStore Limits);
|
||||
TestPipelineStore PipelineStore,
|
||||
TestKanjStore KanjStore,
|
||||
TestSettingsStore Settings,
|
||||
TestTenantLimitStore Limits);
|
||||
|
||||
private static Context CreateContext(
|
||||
int port,
|
||||
FakeTenantLimitStore? limits = null,
|
||||
TestTenantLimitStore? limits = null,
|
||||
bool budgeted = false)
|
||||
{
|
||||
var settings = new FakeSettingsStore();
|
||||
var pipelineStore = new FakePipelineStore();
|
||||
var kanjStore = new FakeKanjStore();
|
||||
var mlClient = new FakeMlClient { Predict = NotReadyPrediction() };
|
||||
var rules = new IncomingRules(settings);
|
||||
var fieldsParser = new LocalFieldsParser(settings);
|
||||
var settings = new TestSettingsStore();
|
||||
var pipelineStore = new TestPipelineStore();
|
||||
var kanjStore = new TestKanjStore();
|
||||
var mlClient = new TestMlClient { Predict = NotReadyPrediction() };
|
||||
var rules = new IncomingRules(settings.Store);
|
||||
var fieldsParser = new LocalFieldsParser(settings.Store);
|
||||
|
||||
ITenantContext tenantContext = new TenantContext();
|
||||
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
||||
var connection = new AiGrpcConnection(
|
||||
new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
|
||||
limits ??= new FakeTenantLimitStore();
|
||||
limits ??= new TestTenantLimitStore();
|
||||
var grpcClassifier = new GrpcAiClassifier(
|
||||
tenantContext,
|
||||
connection,
|
||||
new AiProviderConfigBuilder(settings, TestCiphers.New()),
|
||||
new AiClassifyContextBuilder(settings, kanjStore),
|
||||
new TokenUsageRecorder(settings, limits, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
|
||||
new AiProviderConfigBuilder(settings.Store, TestCiphers.New()),
|
||||
new AiClassifyContextBuilder(settings.Store, kanjStore.Store),
|
||||
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
|
||||
NullLogger<GrpcAiClassifier>.Instance);
|
||||
IAiClassifier aiClassifier = budgeted
|
||||
? new BudgetedAiClassifier(
|
||||
grpcClassifier,
|
||||
new LocalAiClassifier(fieldsParser),
|
||||
limits,
|
||||
limits.Store,
|
||||
tenantContext,
|
||||
NullLogger<BudgetedAiClassifier>.Instance)
|
||||
: grpcClassifier;
|
||||
|
||||
var processing = new PipelineProcessingService(pipelineStore, mlClient, new PipelineIngestService(pipelineStore));
|
||||
var composer = new CardComposer(kanjStore, settings);
|
||||
var writer = new PipelineCardWriter(kanjStore, pipelineStore, composer);
|
||||
var processing = new PipelineProcessingService(pipelineStore.Store, mlClient.Client, new PipelineIngestService(pipelineStore.Store));
|
||||
var composer = new CardComposer(kanjStore.Store, settings.Store);
|
||||
var writer = new PipelineCardWriter(kanjStore.Store, pipelineStore.Store, composer);
|
||||
var worker = new PipelineWorkerService(
|
||||
pipelineStore, settings, rules, kanjStore, mlClient, aiClassifier, processing, writer, fieldsParser);
|
||||
pipelineStore.Store, settings.Store, rules, kanjStore.Store, mlClient.Client, aiClassifier, processing, writer, fieldsParser);
|
||||
return new Context(worker, pipelineStore, kanjStore, settings, limits);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Tests.Unit.Contracts;
|
||||
using Deal.Tests.Unit.Modules.Cards;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
|
||||
namespace Deal.Tests.Unit.Infrastructure;
|
||||
@@ -21,7 +20,7 @@ public sealed class CardMoverTests
|
||||
[Fact]
|
||||
public async Task Move_ToStage_RoutesToStageMoveAndResetsReminder()
|
||||
{
|
||||
(CardMover mover, FakeKanjStore store) = Create();
|
||||
(CardMover mover, TestKanjStore store) = Create();
|
||||
store.SeedCard(new CardDto
|
||||
{
|
||||
Id = "c_1",
|
||||
@@ -44,7 +43,7 @@ public sealed class CardMoverTests
|
||||
[Fact]
|
||||
public async Task Move_ToBoard_RoutesToDashboard()
|
||||
{
|
||||
(CardMover mover, FakeKanjStore store) = Create();
|
||||
(CardMover mover, TestKanjStore store) = Create();
|
||||
store.SeedBoard(new ContainerDto { Id = "b_py", Name = "Python" });
|
||||
store.SeedCard(new CardDto { Id = "c_1", Col = KanbanColumns.Inbox, Content = new SourceContent { Text = "Нужен Python" } });
|
||||
|
||||
@@ -59,7 +58,7 @@ public sealed class CardMoverTests
|
||||
[Fact]
|
||||
public async Task Move_UnknownTarget_ReturnsDashboardInvalidTargetError()
|
||||
{
|
||||
(CardMover mover, FakeKanjStore store) = Create();
|
||||
(CardMover mover, TestKanjStore store) = Create();
|
||||
store.SeedCard(new CardDto { Id = "c_1", Col = KanbanColumns.Inbox, Content = new SourceContent { Text = "Текст" } });
|
||||
|
||||
CardMoveResultDto result = await mover.MoveAsync("c_1", "b_ghost", UserMove, CancellationToken.None);
|
||||
@@ -79,10 +78,10 @@ public sealed class CardMoverTests
|
||||
Assert.False(result.Exists);
|
||||
}
|
||||
|
||||
private static (CardMover Mover, FakeKanjStore Store) Create()
|
||||
private static (CardMover Mover, TestKanjStore Store) Create()
|
||||
{
|
||||
var store = new FakeKanjStore();
|
||||
var cardsService = new CardsService(store, new FakeSettingsStore(), new FakeMlClient(), new TestFileStorage().Storage);
|
||||
var store = new TestKanjStore();
|
||||
var cardsService = new CardsService(store.Store, new TestSettingsStore().Store, new TestMlClient().Client, new TestFileStorage().Storage);
|
||||
return (new CardMover(cardsService), store);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -40,8 +40,8 @@ public sealed class DiscoveryBanGuardTests
|
||||
|
||||
// Вчерашние вступления в суточную квоту не входят: 49 сегодня → ещё можно.
|
||||
TestDiscoveryStore yesterdayStore = new(() => Now);
|
||||
var settings = new FakeSettingsStore();
|
||||
var secondGuard = new DiscoveryBanGuard(yesterdayStore.Store, settings, () => Now);
|
||||
var settings = new TestSettingsStore();
|
||||
var secondGuard = new DiscoveryBanGuard(yesterdayStore.Store, settings.Store, () => Now);
|
||||
for (int i = 0; i < 49; i++)
|
||||
{
|
||||
yesterdayStore.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddMinutes(i));
|
||||
@@ -67,7 +67,7 @@ public sealed class DiscoveryBanGuardTests
|
||||
[Fact]
|
||||
public async Task NoteFlood_BlocksAutoJoinsUntilEndOfDay()
|
||||
{
|
||||
(DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create();
|
||||
(DiscoveryBanGuard guard, _, TestSettingsStore settings) = Create();
|
||||
|
||||
await guard.NoteFloodAsync(CancellationToken.None);
|
||||
|
||||
@@ -79,7 +79,7 @@ public sealed class DiscoveryBanGuardTests
|
||||
[Fact]
|
||||
public async Task FloodYesterday_DoesNotBlockToday()
|
||||
{
|
||||
(DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create();
|
||||
(DiscoveryBanGuard guard, _, TestSettingsStore settings) = Create();
|
||||
settings.Preload(SettingsKeys.DiscFloodDay, TodayStart.AddDays(-1).ToUnixTimeMilliseconds().ToString());
|
||||
|
||||
Assert.False(await guard.FloodTodayAsync(CancellationToken.None));
|
||||
@@ -89,7 +89,7 @@ public sealed class DiscoveryBanGuardTests
|
||||
[Fact]
|
||||
public async Task GlobalPaused_BlocksAutoJoins()
|
||||
{
|
||||
(DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create();
|
||||
(DiscoveryBanGuard guard, _, TestSettingsStore settings) = Create();
|
||||
settings.Preload(SettingsKeys.DiscPaused, "true");
|
||||
|
||||
Assert.True(await guard.GlobalPausedAsync(CancellationToken.None));
|
||||
@@ -99,7 +99,7 @@ public sealed class DiscoveryBanGuardTests
|
||||
[Fact]
|
||||
public async Task CustomJoinLimit_AppliedWhenSettingStored()
|
||||
{
|
||||
(DiscoveryBanGuard guard, TestDiscoveryStore store, FakeSettingsStore settings) = Create();
|
||||
(DiscoveryBanGuard guard, TestDiscoveryStore store, TestSettingsStore settings) = Create();
|
||||
settings.Preload(SettingsKeys.DiscJoinLimit, "2");
|
||||
|
||||
Assert.True(await guard.CanAutoJoinAsync(CancellationToken.None));
|
||||
@@ -109,11 +109,11 @@ public sealed class DiscoveryBanGuardTests
|
||||
}
|
||||
|
||||
// Создаёт связку гард + фейки на фиксированных «часах» (store и гард смотрят одно время).
|
||||
private static (DiscoveryBanGuard Guard, TestDiscoveryStore Store, FakeSettingsStore Settings) Create()
|
||||
private static (DiscoveryBanGuard Guard, TestDiscoveryStore Store, TestSettingsStore Settings) Create()
|
||||
{
|
||||
var store = new TestDiscoveryStore(() => Now);
|
||||
var settings = new FakeSettingsStore();
|
||||
var guard = new DiscoveryBanGuard(store.Store, settings, () => Now);
|
||||
var settings = new TestSettingsStore();
|
||||
var guard = new DiscoveryBanGuard(store.Store, settings.Store, () => Now);
|
||||
return (guard, store, settings);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -117,7 +117,7 @@ public sealed class DiscoveryTasksServiceTests
|
||||
[Fact]
|
||||
public async Task Create_CustomThresholdAndSample_Respected()
|
||||
{
|
||||
(DiscoveryTasksService service, TestDiscoveryStore store, FakeSettingsStore settings) = Create();
|
||||
(DiscoveryTasksService service, TestDiscoveryStore store, TestSettingsStore settings) = Create();
|
||||
settings.Preload("discEvalThreshold", "60");
|
||||
settings.Preload("discEvalSample", "20");
|
||||
|
||||
@@ -330,12 +330,12 @@ public sealed class DiscoveryTasksServiceTests
|
||||
}
|
||||
|
||||
// Создаёт связку сервис + фейки (пустые настройки → дефолты SettingsDefaults).
|
||||
private static (DiscoveryTasksService Service, TestDiscoveryStore Store, FakeSettingsStore Settings) Create()
|
||||
private static (DiscoveryTasksService Service, TestDiscoveryStore Store, TestSettingsStore Settings) Create()
|
||||
{
|
||||
var store = new TestDiscoveryStore();
|
||||
var settings = new FakeSettingsStore();
|
||||
var planGuard = new DiscoveryPlanGuard(store.Store, settings);
|
||||
return (new DiscoveryTasksService(store.Store, planGuard, settings), store, settings);
|
||||
var settings = new TestSettingsStore();
|
||||
var planGuard = new DiscoveryPlanGuard(store.Store, settings.Store);
|
||||
return (new DiscoveryTasksService(store.Store, planGuard, settings.Store), store, settings);
|
||||
}
|
||||
|
||||
// Draft задачи с дефолтными полями (name/planJoins переопределяются).
|
||||
|
||||
@@ -1,614 +0,0 @@
|
||||
using Deal.Modules.Discovery.Application.Abstractions;
|
||||
using Deal.Modules.Discovery.Application.Models;
|
||||
|
||||
namespace Deal.Tests.Unit.Modules.Discovery;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory реализация <see cref="IDiscoveryStore"/> для unit-тестов Discovery.
|
||||
/// </summary>
|
||||
public sealed class FakeDiscoveryStore : IDiscoveryStore
|
||||
{
|
||||
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 FakeDiscoveryStore(Func<DateTimeOffset>? clock = null)
|
||||
{
|
||||
_clock = clock ?? (() => DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
/// <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()));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<DiscoveryTaskDto>> ListTasksAsync(CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<DiscoveryTaskDto>>(_tasks
|
||||
.OrderBy(task => task.CreatedAt)
|
||||
.ToList());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DiscoveryTaskDto?> GetTaskAsync(string taskId, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(_tasks.FirstOrDefault(task => task.Id == taskId));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public 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));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public 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());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DiscoveryCandidateDto?> GetCandidateAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(_candidates.FirstOrDefault(candidate => candidate.DialogId == dialogId));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> IsDialogMonitoredAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(_monitoredIds.Contains(dialogId));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> IsBlacklistedAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(_blacklist.Any(entry => entry.DialogId == dialogId));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteCandidateAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
_candidates.RemoveAll(candidate => candidate.DialogId == dialogId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public 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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RemoveBlacklistAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
_blacklist.RemoveAll(entry => entry.DialogId == dialogId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<DiscoveryBlacklistDto?> GetBlacklistAsync(string dialogId, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(_blacklist.FirstOrDefault(entry => entry.DialogId == dialogId));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<DiscoveryBlacklistDto>> ListBlacklistAsync(CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<DiscoveryBlacklistDto>>(_blacklist
|
||||
.OrderByDescending(entry => entry.CreatedAt)
|
||||
.ToList());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public 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));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public 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);
|
||||
}
|
||||
}
|
||||
@@ -2,8 +2,8 @@ using Deal.Modules.Cards.Application.Sources;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using Deal.Modules.Settings.Application.Services;
|
||||
using Deal.Tests.Unit.Modules.Cards;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
|
||||
namespace Deal.Tests.Unit.Modules.Kanban;
|
||||
@@ -24,11 +24,11 @@ public sealed class AiClassifyContextBuilderTests
|
||||
[Fact]
|
||||
public async Task BuildFilterPrompt_FillsDomainAndKeywords()
|
||||
{
|
||||
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
|
||||
TestSettingsStore settings = Context(out TestKanjStore kanj);
|
||||
settings.Preload(SettingsKeys.AiFilterPrompt, Json("Страж. Сфера: {domain}. Маркеры: {keywords}."));
|
||||
settings.Preload(SettingsKeys.DomainDescription, Json(Domain));
|
||||
settings.Preload(SettingsKeys.DomainKeywords, Json(Keywords));
|
||||
var builder = new AiClassifyContextBuilder(settings, kanj);
|
||||
var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store);
|
||||
|
||||
string prompt = await builder.BuildFilterPromptAsync(CancellationToken.None);
|
||||
|
||||
@@ -38,9 +38,9 @@ public sealed class AiClassifyContextBuilderTests
|
||||
[Fact]
|
||||
public async Task BuildFilterPrompt_NoDomain_UsesFallbackTextAndHint()
|
||||
{
|
||||
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
|
||||
TestSettingsStore settings = Context(out TestKanjStore kanj);
|
||||
settings.Preload(SettingsKeys.AiFilterPrompt, Json("[{domain}] {keywords}"));
|
||||
var builder = new AiClassifyContextBuilder(settings, kanj);
|
||||
var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store);
|
||||
|
||||
string prompt = await builder.BuildFilterPromptAsync(CancellationToken.None);
|
||||
|
||||
@@ -52,12 +52,12 @@ public sealed class AiClassifyContextBuilderTests
|
||||
[Fact]
|
||||
public async Task BuildClassifySystemPrompt_AppendsCardPromptWhenSet()
|
||||
{
|
||||
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
|
||||
TestSettingsStore settings = Context(out TestKanjStore kanj);
|
||||
settings.Preload(SettingsKeys.AiPrompt, Json("Классифицируй {domain}."));
|
||||
settings.Preload(SettingsKeys.CardPrompt, Json("Верни блок «О заявке» {keywords}."));
|
||||
settings.Preload(SettingsKeys.DomainDescription, Json(Domain));
|
||||
settings.Preload(SettingsKeys.DomainKeywords, Json(Keywords));
|
||||
var builder = new AiClassifyContextBuilder(settings, kanj);
|
||||
var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store);
|
||||
|
||||
string prompt = await builder.BuildClassifySystemPromptAsync(CancellationToken.None);
|
||||
|
||||
@@ -69,8 +69,8 @@ public sealed class AiClassifyContextBuilderTests
|
||||
[Fact]
|
||||
public async Task BuildClassifyUserContext_EmptyBoards_ShowsNoBoardsPhrase()
|
||||
{
|
||||
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
|
||||
var builder = new AiClassifyContextBuilder(settings, kanj);
|
||||
TestSettingsStore settings = Context(out TestKanjStore kanj);
|
||||
var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store);
|
||||
|
||||
string context = await builder.BuildClassifyUserContextAsync("Нужен разработчик", CancellationToken.None);
|
||||
|
||||
@@ -82,7 +82,7 @@ public sealed class AiClassifyContextBuilderTests
|
||||
[Fact]
|
||||
public async Task BuildClassifyUserContext_ListsBoardsWithKeywordsAndRules()
|
||||
{
|
||||
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
|
||||
TestSettingsStore settings = Context(out TestKanjStore kanj);
|
||||
kanj.SeedBoard(new ContainerDto
|
||||
{
|
||||
Id = "b_py",
|
||||
@@ -113,7 +113,7 @@ public sealed class AiClassifyContextBuilderTests
|
||||
Order = 1,
|
||||
});
|
||||
kanj.SeedBoard(new ContainerDto { Id = "b_sug", Name = "Предложение", Suggested = true, Order = 2 });
|
||||
var builder = new AiClassifyContextBuilder(settings, kanj);
|
||||
var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store);
|
||||
|
||||
string context = await builder.BuildClassifyUserContextAsync("Ищу Python-разработчика", CancellationToken.None);
|
||||
|
||||
@@ -128,10 +128,10 @@ public sealed class AiClassifyContextBuilderTests
|
||||
[Fact]
|
||||
public async Task BuildClassifyUserContext_IncludesMarkupExamplesNewestFirst()
|
||||
{
|
||||
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
|
||||
TestSettingsStore settings = Context(out TestKanjStore kanj);
|
||||
SeedCardWithMove(kanj, "l_1", "b_py", "Пример: middle python (старый)");
|
||||
SeedCardWithMove(kanj, "l_2", "b_py", "Пример: senior go (свежий)");
|
||||
var builder = new AiClassifyContextBuilder(settings, kanj);
|
||||
var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store);
|
||||
|
||||
string context = await builder.BuildClassifyUserContextAsync("Ищу разработчика", CancellationToken.None);
|
||||
|
||||
@@ -144,8 +144,8 @@ public sealed class AiClassifyContextBuilderTests
|
||||
[Fact]
|
||||
public async Task BuildClassifyUserContext_LongMessage_TruncatesTo5000CodePoints()
|
||||
{
|
||||
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
|
||||
var builder = new AiClassifyContextBuilder(settings, kanj);
|
||||
TestSettingsStore settings = Context(out TestKanjStore kanj);
|
||||
var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store);
|
||||
string text = new string('д', 6000);
|
||||
|
||||
string context = await builder.BuildClassifyUserContextAsync(text, CancellationToken.None);
|
||||
@@ -158,10 +158,10 @@ public sealed class AiClassifyContextBuilderTests
|
||||
// Создаёт контекст: пустое KV + пустой канбан-фейк.
|
||||
// kanj: Канбан-фейк (доски/журнал примеров).
|
||||
// Возвращает: KV-хранилище тенанта.
|
||||
private static FakeSettingsStore Context(out FakeKanjStore kanj)
|
||||
private static TestSettingsStore Context(out TestKanjStore kanj)
|
||||
{
|
||||
kanj = new FakeKanjStore();
|
||||
return new FakeSettingsStore();
|
||||
kanj = new TestKanjStore();
|
||||
return new TestSettingsStore();
|
||||
}
|
||||
|
||||
// Кладёт карточку с исходным текстом и запись журнала move на неё (источник few-shot-примера).
|
||||
@@ -170,12 +170,12 @@ public sealed class AiClassifyContextBuilderTests
|
||||
// board: Колонка-назначение (доска).
|
||||
// text: Исходный текст карточки (source_msg).
|
||||
private static void SeedCardWithMove(
|
||||
FakeKanjStore kanj,
|
||||
TestKanjStore kanj,
|
||||
string cardId,
|
||||
string board,
|
||||
string text)
|
||||
{
|
||||
kanj.SeedCard(new CardDto { Id = cardId, Col = board, Content = new SourceContent { Text = text } });
|
||||
kanj.AddMoveAsync(new CardMoveDto("lm_" + cardId, cardId, "move", "inbox", board), CancellationToken.None).GetAwaiter().GetResult();
|
||||
kanj.Store.AddMoveAsync(new CardMoveDto("lm_" + cardId, cardId, "move", "inbox", board), CancellationToken.None).GetAwaiter().GetResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ using Deal.Tests.Unit.Support;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Tests.Unit.Contracts;
|
||||
using Deal.Tests.Unit.Modules.Cards;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
|
||||
namespace Deal.Tests.Unit.Modules.Kanban;
|
||||
@@ -19,7 +18,7 @@ public sealed class CardsServiceRemindersTests
|
||||
[Fact]
|
||||
public async Task Set_RemindersDisabled_Returns400TextAndWritesNothing()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false);
|
||||
(CardsService service, TestKanjStore store, _) = Create(remindersEnabled: false);
|
||||
store.SeedCard(Card("c_1", stage: "hold", title: "Отложенный бот"));
|
||||
|
||||
CardResultDto result = await service.SetReminderAsync("c_1", NowMs() + DayMs, CancellationToken.None);
|
||||
@@ -32,7 +31,7 @@ public sealed class CardsServiceRemindersTests
|
||||
[Fact]
|
||||
public async Task Set_Enabled_SetsReminderAndReturnsCard()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _) = Create();
|
||||
store.SeedCard(Card("c_1", stage: "hold", title: "Отложенный бот", updatedAtMs: 1));
|
||||
long atMs = NowMs() + DayMs;
|
||||
|
||||
@@ -61,7 +60,7 @@ public sealed class CardsServiceRemindersTests
|
||||
[Fact]
|
||||
public async Task Set_StageNotHold_Allowed()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _) = Create();
|
||||
store.SeedCard(Card("c_1", stage: "work", title: "В работе"));
|
||||
long atMs = NowMs() + DayMs;
|
||||
|
||||
@@ -76,7 +75,7 @@ public sealed class CardsServiceRemindersTests
|
||||
[Fact]
|
||||
public async Task Clear_WithReminder_ClearsItAndReturnsTrue()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _) = Create();
|
||||
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) });
|
||||
|
||||
bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None);
|
||||
@@ -98,7 +97,7 @@ public sealed class CardsServiceRemindersTests
|
||||
[Fact]
|
||||
public async Task Clear_RemindersDisabled_StillClears()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false);
|
||||
(CardsService service, TestKanjStore store, _) = Create(remindersEnabled: false);
|
||||
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() + DayMs) });
|
||||
|
||||
bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None);
|
||||
@@ -111,7 +110,7 @@ public sealed class CardsServiceRemindersTests
|
||||
[Fact]
|
||||
public async Task Snooze_MovesReminderToNowPlus24h()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _) = Create();
|
||||
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) });
|
||||
long beforeMs = NowMs();
|
||||
|
||||
@@ -136,7 +135,7 @@ public sealed class CardsServiceRemindersTests
|
||||
[Fact]
|
||||
public async Task Snooze_RemindersDisabled_StillSnoozes()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false);
|
||||
(CardsService service, TestKanjStore store, _) = Create(remindersEnabled: false);
|
||||
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) });
|
||||
|
||||
bool snoozed = await service.SnoozeReminderAsync("c_1", CancellationToken.None);
|
||||
@@ -149,7 +148,7 @@ public sealed class CardsServiceRemindersTests
|
||||
[Fact]
|
||||
public async Task CheckDue_Disabled_ClearsExpiredAndReturnsEmpty()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false);
|
||||
(CardsService service, TestKanjStore store, _) = Create(remindersEnabled: false);
|
||||
long pastMs = NowMs() - 1;
|
||||
store.SeedCard(Card("c_hold_past", stage: "hold") with { Reminder = new CardReminderDto(pastMs) });
|
||||
store.SeedCard(Card("c_work_past", stage: "work") with { Reminder = new CardReminderDto(pastMs) });
|
||||
@@ -167,7 +166,7 @@ public sealed class CardsServiceRemindersTests
|
||||
[Fact]
|
||||
public async Task CheckDue_Enabled_ReturnsDueAndMarksFired()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _) = Create();
|
||||
store.SeedCard(Card("c_d1", stage: "hold", title: "Ранний") with { Reminder = new CardReminderDto(NowMs() - 2 * DayMs) });
|
||||
store.SeedCard(Card("c_d2", stage: "hold", title: "Поздний") with { Reminder = new CardReminderDto(NowMs() - 1) });
|
||||
store.SeedCard(Card("c_future", stage: "hold", title: "Будущий") with { Reminder = new CardReminderDto(NowMs() + DayMs) });
|
||||
@@ -181,21 +180,21 @@ public sealed class CardsServiceRemindersTests
|
||||
due.Select(item => (item.Id, item.Title)));
|
||||
Assert.All(due, item => Assert.Equal("hold", item.ContainerId));
|
||||
// «Выстрелившие» помечены fired: повторная выборка due пуста (признак держит строка БД).
|
||||
Assert.Empty(await store.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None));
|
||||
Assert.Empty(await store.Store.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None));
|
||||
}
|
||||
|
||||
// ─── Хелперы ──────────────────────────────────────────────────────────
|
||||
|
||||
private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings) Create(bool remindersEnabled = true)
|
||||
private static (CardsService Service, TestKanjStore Store, TestSettingsStore Settings) Create(bool remindersEnabled = true)
|
||||
{
|
||||
var store = new FakeKanjStore();
|
||||
var settings = new FakeSettingsStore();
|
||||
var store = new TestKanjStore();
|
||||
var settings = new TestSettingsStore();
|
||||
if (!remindersEnabled)
|
||||
{
|
||||
settings.Preload(SettingsKeys.RemindersEnabled, "false");
|
||||
}
|
||||
|
||||
return (new CardsService(store, settings, new FakeMlClient(), new TestFileStorage().Storage), store, settings);
|
||||
return (new CardsService(store.Store, settings.Store, new TestMlClient().Client, new TestFileStorage().Storage), store, settings);
|
||||
}
|
||||
|
||||
// Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем.
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
|
||||
namespace Deal.Tests.Unit.Modules.Kanban;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory реализация <see cref="IMlLearningStore"/> для unit-тестов ML-интеграции.
|
||||
/// </summary>
|
||||
public sealed class FakeMlLearningStore : IMlLearningStore
|
||||
{
|
||||
private readonly List<(string Id, string Text, string Label, double Delta)> _rows = [];
|
||||
|
||||
/// <summary>
|
||||
/// Число записей журнала обучения
|
||||
/// </summary>
|
||||
public int LearningCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Строки очереди
|
||||
/// </summary>
|
||||
public IReadOnlyList<(string Id, string Text, string Label, double Delta)> AddedRows => _rows.ToList();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CountLearningAsync(CancellationToken ct) => Task.FromResult(LearningCount);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CountOutboxAsync(CancellationToken ct) => Task.FromResult(_rows.Count);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddOutboxAsync(
|
||||
string id,
|
||||
string text,
|
||||
string label,
|
||||
double delta,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_rows.Add((id, text, label, delta));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Пишет строку очереди напрямую
|
||||
/// </summary>
|
||||
/// <param name="id">Id строки (<c>mle_...</c>).</param>
|
||||
/// <param name="text">Текст обучающего примера.</param>
|
||||
/// <param name="label">Метка обучения.</param>
|
||||
/// <param name="delta">Вес сигнала.</param>
|
||||
public void SeedOutbox(
|
||||
string id,
|
||||
string text,
|
||||
string label,
|
||||
double delta = 1.0)
|
||||
{
|
||||
_rows.Add((id, text, label, delta));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ClearOutboxAsync(CancellationToken ct)
|
||||
{
|
||||
_rows.Clear();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<MlOutboxEntryDto>> TakeOutboxBatchAsync(int limit, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<MlOutboxEntryDto>>(
|
||||
_rows.Take(limit)
|
||||
.Select(row => new MlOutboxEntryDto(row.Id, row.Text, row.Label, row.Delta))
|
||||
.ToList());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteOutboxAsync(IReadOnlyCollection<string> ids, CancellationToken ct)
|
||||
{
|
||||
_rows.RemoveAll(row => ids.Contains(row.Id));
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,315 +0,0 @@
|
||||
using Deal.Modules.Cards.Application.Sources;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Abstractions;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
|
||||
namespace Deal.Tests.Unit.Modules.Kanban;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory реализация <see cref="IPipelineStore"/> для unit-тестов PipelineIngestService/PipelineProcessingService.
|
||||
/// </summary>
|
||||
public class FakePipelineStore : IPipelineStore
|
||||
{
|
||||
private readonly List<QueueItemDto> _queue = [];
|
||||
private readonly List<RejectedItemDto> _rejected = [];
|
||||
private readonly Dictionary<string, string?> _dedup = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Строки очереди фейка
|
||||
/// </summary>
|
||||
public IReadOnlyList<QueueItemDto> Queue => _queue.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Записи отсева фейка
|
||||
/// </summary>
|
||||
public IReadOnlyList<RejectedItemDto> Rejected => _rejected.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Кладёт строку очереди напрямую
|
||||
/// </summary>
|
||||
/// <param name="item">Строка как если бы была сохранена в БД.</param>
|
||||
public void SeedQueue(QueueItemDto item)
|
||||
{
|
||||
_queue.Add(item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Кладёт запись отсева напрямую
|
||||
/// </summary>
|
||||
/// <param name="item">Запись как если бы была сохранена в БД.</param>
|
||||
public void SeedRejected(RejectedItemDto item)
|
||||
{
|
||||
_rejected.Add(item);
|
||||
}
|
||||
|
||||
// ── Очередь (QueueItems) ───────────────────────────────────────────────
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ExistsDuplicateAsync(
|
||||
SourceRef source,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string key = source.DedupeKey();
|
||||
bool found = _queue.Any(item => item.Source.DedupeKey() == key);
|
||||
return Task.FromResult(found);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddAsync(QueueItemDto item, CancellationToken ct)
|
||||
{
|
||||
_queue.Add(item);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual Task<IReadOnlyList<QueueItemDto>> ListAsync(
|
||||
string? status,
|
||||
int limit,
|
||||
CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<QueueItemDto> items = _queue
|
||||
.Where(item => status is null || item.Status == status)
|
||||
.OrderBy(item => item.QueuedAtMs)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
return Task.FromResult(items);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CountByStatusAsync(string status, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(_queue.Count(item => item.Status == status));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetStatusAsync(
|
||||
string id,
|
||||
string status,
|
||||
CancellationToken ct)
|
||||
{
|
||||
int index = _queue.FindIndex(item => item.Id == id);
|
||||
if (index >= 0)
|
||||
{
|
||||
_queue[index] = _queue[index] with { Status = status };
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RemoveAsync(string id, CancellationToken ct)
|
||||
{
|
||||
_queue.RemoveAll(item => item.Id == id);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── Отсев (RejectedItems) ──────────────────────────────────────────────
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpsertAsync(RejectRecord record, CancellationToken ct)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(record.Text))
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
string id = record.DeterministicId ?? PrefixId.New(PipelineIdPrefixes.Rejected);
|
||||
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
int index = _rejected.FindIndex(item => item.Id == id);
|
||||
RejectedItemDto row = ToItem(record, id, nowMs);
|
||||
if (index >= 0)
|
||||
{
|
||||
RejectedItemDto existing = _rejected[index];
|
||||
row = row with
|
||||
{
|
||||
Returned = existing.Returned,
|
||||
ReturnedAtMs = existing.ReturnedAtMs,
|
||||
ReturnReason = existing.ReturnReason,
|
||||
};
|
||||
_rejected[index] = row;
|
||||
}
|
||||
else
|
||||
{
|
||||
_rejected.Add(row);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<RejectedItemDto>> ListPageAsync(
|
||||
int offset,
|
||||
int limit,
|
||||
CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<RejectedItemDto> page = _rejected
|
||||
.OrderByDescending(item => item.RejectedAtMs)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
return Task.FromResult(page);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<RejectedItemDto>> SearchAsync(
|
||||
string q,
|
||||
int limitFts,
|
||||
int limitLike,
|
||||
CancellationToken ct)
|
||||
{
|
||||
string query = q.Trim();
|
||||
IReadOnlyList<RejectedItemDto> rows = _rejected
|
||||
.Where(item => LikeField(item.Text, query)
|
||||
|| LikeField(item.Reason, query)
|
||||
|| LikeField(item.Kw, query)
|
||||
|| LikeField(item.Source.DisplayName ?? string.Empty, query))
|
||||
.OrderByDescending(item => item.RejectedAtMs)
|
||||
.Take(limitLike)
|
||||
.ToList();
|
||||
return Task.FromResult(rows);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CountAsync(CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(_rejected.Count);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<RejectedItemDto?> GetAsync(string id, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(_rejected.FirstOrDefault(item => item.Id == id));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteAsync(string id, CancellationToken ct)
|
||||
{
|
||||
_rejected.RemoveAll(item => item.Id == id);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> ClearAsync(CancellationToken ct)
|
||||
{
|
||||
int cleared = _rejected.Count;
|
||||
_rejected.Clear();
|
||||
return Task.FromResult(cleared);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> PurgeExpiredAsync(DateTimeOffset olderThan, CancellationToken ct)
|
||||
{
|
||||
long cutMs = olderThan.ToUnixTimeMilliseconds();
|
||||
int removed = _rejected.RemoveAll(item => item.RejectedAtMs < cutMs);
|
||||
return Task.FromResult(removed);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task MarkReturnedAsync(
|
||||
string id,
|
||||
string reason,
|
||||
DateTimeOffset returnedAt,
|
||||
CancellationToken ct)
|
||||
{
|
||||
int index = _rejected.FindIndex(item => item.Id == id);
|
||||
if (index >= 0)
|
||||
{
|
||||
_rejected[index] = _rejected[index] with
|
||||
{
|
||||
Returned = true,
|
||||
ReturnedAtMs = returnedAt.ToUnixTimeMilliseconds(),
|
||||
ReturnReason = reason,
|
||||
};
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── Дедуп (DedupEntries) ───────────────────────────────────────────────
|
||||
|
||||
/// <summary>
|
||||
/// Id карточки, связанной с хэшем дедупа
|
||||
/// </summary>
|
||||
/// <param name="hash">SHA1-hex нормализованного текста.</param>
|
||||
public string? DedupLeadId(string hash)
|
||||
{
|
||||
return _dedup.TryGetValue(hash, out string? leadId) ? leadId : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual Task<bool> ExistsAsync(string hash, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(_dedup.ContainsKey(hash));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual Task<bool> ClaimAsync(string hash, CancellationToken ct)
|
||||
{
|
||||
// Атомарность claim'а (как PipelineStore.ClaimAsync, ON CONFLICT DO NOTHING): true — заявка занята
|
||||
// этим вызовом, false — хэш уже заявлен (TryAdd) другим проходом pump.
|
||||
return Task.FromResult(_dedup.TryAdd(hash, null));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual Task DeleteClaimAsync(string hash, CancellationToken ct)
|
||||
{
|
||||
if (_dedup.TryGetValue(hash, out string? leadId) && leadId is null)
|
||||
{
|
||||
_dedup.Remove(hash);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task LinkAsync(
|
||||
string hash,
|
||||
string cardId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_dedup[hash] = cardId;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteByCardAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
foreach (string hash in _dedup.Where(pair => pair.Value == cardId).Select(pair => pair.Key).ToList())
|
||||
{
|
||||
_dedup.Remove(hash);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── Маппинг (как адаптер: подписи — словари PipelineRejectConstants) ────
|
||||
|
||||
// Подстрока q в lower(поле) — LIKE-половина адаптера (поле колонки SQL приводится к нижнему регистру).
|
||||
// field: Поле записи отсева (text/reason/kw/ch_name).
|
||||
// query: Поисковый запрос, нормализованный сервисом (trim+lowercase).
|
||||
// Возвращает: True — поле содержит запрос без учёта регистра поля.
|
||||
private static bool LikeField(string field, string query)
|
||||
{
|
||||
return field.ToLowerInvariant().Contains(query, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static RejectedItemDto ToItem(
|
||||
RejectRecord record,
|
||||
string id,
|
||||
long rejectedAtMs) => new()
|
||||
{
|
||||
Id = id,
|
||||
Source = record.Source,
|
||||
Content = record.Content,
|
||||
Text = record.Text,
|
||||
Stage = record.Stage,
|
||||
StageLabel = PipelineRejectConstants.StageLabel(record.Stage),
|
||||
Reason = record.Reason,
|
||||
Kw = record.Kw,
|
||||
DecidedBy = record.DecidedBy,
|
||||
DecidedByLabel = PipelineRejectConstants.SourceLabel(record.DecidedBy),
|
||||
MsgAtMs = record.MsgAtMs,
|
||||
RejectedAtMs = rejectedAtMs,
|
||||
};
|
||||
}
|
||||
@@ -5,7 +5,6 @@ using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
using Deal.Tests.Unit.Contracts;
|
||||
using Deal.Tests.Unit.Modules.Cards;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
using Deal.Tests.Unit.Support;
|
||||
|
||||
@@ -19,15 +18,15 @@ public sealed class MlReviewServiceTests
|
||||
private const string Dialog = "d_1";
|
||||
|
||||
private static MlReviewService Create(
|
||||
FakePipelineStore pipeline,
|
||||
FakeKanjStore kanj,
|
||||
FakeMlClient ml,
|
||||
TestPipelineStore pipeline,
|
||||
TestKanjStore kanj,
|
||||
TestMlClient ml,
|
||||
out CardsService cards)
|
||||
{
|
||||
var settings = new FakeSettingsStore();
|
||||
cards = new CardsService(kanj, settings, ml, new TestFileStorage().Storage);
|
||||
var processing = new PipelineProcessingService(pipeline, ml, new PipelineIngestService(pipeline));
|
||||
return new MlReviewService(pipeline, kanj, cards, processing, ml);
|
||||
var settings = new TestSettingsStore();
|
||||
cards = new CardsService(kanj.Store, settings.Store, ml.Client, new TestFileStorage().Storage);
|
||||
var processing = new PipelineProcessingService(pipeline.Store, ml.Client, new PipelineIngestService(pipeline.Store));
|
||||
return new MlReviewService(pipeline.Store, kanj.Store, cards, processing, ml.Client);
|
||||
}
|
||||
|
||||
private static QueueItemDto QueueRow(
|
||||
@@ -73,12 +72,12 @@ public sealed class MlReviewServiceTests
|
||||
[Fact]
|
||||
public async Task Candidates_MergesQueueRejectedAndCardsWithVerdicts()
|
||||
{
|
||||
var pipeline = new FakePipelineStore();
|
||||
var pipeline = new TestPipelineStore();
|
||||
pipeline.SeedQueue(QueueRow(101, "из очереди"));
|
||||
pipeline.SeedRejected(RejectedRow(102, "из отсева"));
|
||||
var kanj = new FakeKanjStore();
|
||||
var kanj = new TestKanjStore();
|
||||
kanj.SeedCard(Card(103, "из карточки"));
|
||||
MlReviewService service = Create(pipeline, kanj, new FakeMlClient(), out _);
|
||||
MlReviewService service = Create(pipeline, kanj, new TestMlClient(), out _);
|
||||
|
||||
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None);
|
||||
|
||||
@@ -94,10 +93,10 @@ public sealed class MlReviewServiceTests
|
||||
[Fact]
|
||||
public async Task Candidates_FiltersByDialog()
|
||||
{
|
||||
var pipeline = new FakePipelineStore();
|
||||
var pipeline = new TestPipelineStore();
|
||||
pipeline.SeedQueue(QueueRow(101, "нужный", Dialog));
|
||||
pipeline.SeedQueue(QueueRow(201, "другой", "d_2"));
|
||||
MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _);
|
||||
MlReviewService service = Create(pipeline, new TestKanjStore(), new TestMlClient(), out _);
|
||||
|
||||
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None);
|
||||
|
||||
@@ -108,10 +107,10 @@ public sealed class MlReviewServiceTests
|
||||
[Fact]
|
||||
public async Task Candidates_NoDialog_ReturnsAllSources()
|
||||
{
|
||||
var pipeline = new FakePipelineStore();
|
||||
var pipeline = new TestPipelineStore();
|
||||
pipeline.SeedQueue(QueueRow(101, "a", "d_1"));
|
||||
pipeline.SeedQueue(QueueRow(201, "b", "d_2"));
|
||||
MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _);
|
||||
MlReviewService service = Create(pipeline, new TestKanjStore(), new TestMlClient(), out _);
|
||||
|
||||
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(null, 10, CancellationToken.None);
|
||||
|
||||
@@ -121,13 +120,13 @@ public sealed class MlReviewServiceTests
|
||||
[Fact]
|
||||
public async Task Candidates_ClampsLimitToMax()
|
||||
{
|
||||
var pipeline = new FakePipelineStore();
|
||||
var pipeline = new TestPipelineStore();
|
||||
for (int i = 0; i < 5; i++)
|
||||
{
|
||||
pipeline.SeedQueue(QueueRow(100 + i, $"текст {i}"));
|
||||
}
|
||||
|
||||
MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _);
|
||||
MlReviewService service = Create(pipeline, new TestKanjStore(), new TestMlClient(), out _);
|
||||
|
||||
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(Dialog, 2, CancellationToken.None);
|
||||
|
||||
@@ -137,10 +136,10 @@ public sealed class MlReviewServiceTests
|
||||
[Fact]
|
||||
public async Task Apply_Skip_DoesNotLearn()
|
||||
{
|
||||
var pipeline = new FakePipelineStore();
|
||||
var pipeline = new TestPipelineStore();
|
||||
pipeline.SeedQueue(QueueRow(101, "текст"));
|
||||
var ml = new FakeMlClient();
|
||||
MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _);
|
||||
var ml = new TestMlClient();
|
||||
MlReviewService service = Create(pipeline, new TestKanjStore(), ml, out _);
|
||||
|
||||
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSkip, CancellationToken.None);
|
||||
|
||||
@@ -154,10 +153,10 @@ public sealed class MlReviewServiceTests
|
||||
[Fact]
|
||||
public async Task Apply_Spam_WithCard_TrashesAndLearns()
|
||||
{
|
||||
var kanj = new FakeKanjStore();
|
||||
var kanj = new TestKanjStore();
|
||||
kanj.SeedCard(Card(101, "спамный текст"));
|
||||
var ml = new FakeMlClient();
|
||||
MlReviewService service = Create(new FakePipelineStore(), kanj, ml, out _);
|
||||
var ml = new TestMlClient();
|
||||
MlReviewService service = Create(new TestPipelineStore(), kanj, ml, out _);
|
||||
|
||||
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None);
|
||||
|
||||
@@ -172,10 +171,10 @@ public sealed class MlReviewServiceTests
|
||||
[Fact]
|
||||
public async Task Apply_Spam_QueuedMessage_RejectsAndRemovesFromQueue()
|
||||
{
|
||||
var pipeline = new FakePipelineStore();
|
||||
var pipeline = new TestPipelineStore();
|
||||
pipeline.SeedQueue(QueueRow(101, "рекламный текст"));
|
||||
var ml = new FakeMlClient();
|
||||
MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _);
|
||||
var ml = new TestMlClient();
|
||||
MlReviewService service = Create(pipeline, new TestKanjStore(), ml, out _);
|
||||
|
||||
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None);
|
||||
|
||||
@@ -192,7 +191,7 @@ public sealed class MlReviewServiceTests
|
||||
[Fact]
|
||||
public async Task Apply_Board_MovesCardAndLearns()
|
||||
{
|
||||
var kanj = new FakeKanjStore();
|
||||
var kanj = new TestKanjStore();
|
||||
kanj.SeedBoard(new ContainerDto
|
||||
{
|
||||
Id = "b_py",
|
||||
@@ -201,8 +200,8 @@ public sealed class MlReviewServiceTests
|
||||
Kind = ContainerKinds.Board,
|
||||
});
|
||||
kanj.SeedCard(Card(101, "python разработчик"));
|
||||
var ml = new FakeMlClient();
|
||||
MlReviewService service = Create(new FakePipelineStore(), kanj, ml, out _);
|
||||
var ml = new TestMlClient();
|
||||
MlReviewService service = Create(new TestPipelineStore(), kanj, ml, out _);
|
||||
|
||||
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:b_py", CancellationToken.None);
|
||||
|
||||
@@ -216,9 +215,9 @@ public sealed class MlReviewServiceTests
|
||||
[Fact]
|
||||
public async Task Apply_UnknownBoard_ReturnsError()
|
||||
{
|
||||
var kanj = new FakeKanjStore();
|
||||
var kanj = new TestKanjStore();
|
||||
kanj.SeedCard(Card(101, "текст"));
|
||||
MlReviewService service = Create(new FakePipelineStore(), kanj, new FakeMlClient(), out _);
|
||||
MlReviewService service = Create(new TestPipelineStore(), kanj, new TestMlClient(), out _);
|
||||
|
||||
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:missing", CancellationToken.None);
|
||||
|
||||
@@ -230,9 +229,9 @@ public sealed class MlReviewServiceTests
|
||||
[Fact]
|
||||
public async Task Apply_UnknownAction_ReturnsError()
|
||||
{
|
||||
var pipeline = new FakePipelineStore();
|
||||
var pipeline = new TestPipelineStore();
|
||||
pipeline.SeedQueue(QueueRow(101, "текст"));
|
||||
MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _);
|
||||
MlReviewService service = Create(pipeline, new TestKanjStore(), new TestMlClient(), out _);
|
||||
|
||||
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "что-то", CancellationToken.None);
|
||||
|
||||
@@ -244,7 +243,7 @@ public sealed class MlReviewServiceTests
|
||||
[Fact]
|
||||
public async Task Apply_MessageNotFound_ReturnsNull()
|
||||
{
|
||||
MlReviewService service = Create(new FakePipelineStore(), new FakeKanjStore(), new FakeMlClient(), out _);
|
||||
MlReviewService service = Create(new TestPipelineStore(), new TestKanjStore(), new TestMlClient(), out _);
|
||||
|
||||
MlApplyResult? result = await service.ApplyAsync(Dialog, 999, MlReviewService.ActionSpam, CancellationToken.None);
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Tests.Unit.Modules.Cards;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
|
||||
namespace Deal.Tests.Unit.Modules.Kanban;
|
||||
@@ -15,7 +15,7 @@ public sealed class StorageTickServiceTests
|
||||
[Fact]
|
||||
public async Task Tick_DefaultSettings_ArchivesExpiredBoardAndInboxCards()
|
||||
{
|
||||
(StorageTickService service, FakeKanjStore store, _) = Create();
|
||||
(StorageTickService service, TestKanjStore store, _) = Create();
|
||||
store.SeedBoard(Board("b_1"));
|
||||
store.SeedCard(Card("l_board_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(16))));
|
||||
store.SeedCard(Card("l_board_young", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(13)))); // < 14 дн. — не кандидат
|
||||
@@ -47,7 +47,7 @@ public sealed class StorageTickServiceTests
|
||||
[Fact]
|
||||
public async Task Tick_AutoArchiveSettingFalse_KeepsExpiredCardsInPlace()
|
||||
{
|
||||
(StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create();
|
||||
(StorageTickService service, TestKanjStore store, TestSettingsStore settings) = Create();
|
||||
settings.Preload(SettingsKeys.AutoArchive, "false");
|
||||
store.SeedBoard(Board("b_1"));
|
||||
store.SeedCard(Card("l_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(30))));
|
||||
@@ -62,7 +62,7 @@ public sealed class StorageTickServiceTests
|
||||
[Fact]
|
||||
public async Task Tick_ArchiveAfterDays30_ArchivesOnlyCardsOlderThanConfiguredMonth()
|
||||
{
|
||||
(StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create();
|
||||
(StorageTickService service, TestKanjStore store, TestSettingsStore settings) = Create();
|
||||
settings.Preload(SettingsKeys.ArchiveAfterDays, "30");
|
||||
store.SeedBoard(Board("b_1"));
|
||||
store.SeedCard(Card("l_older_month", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(32))));
|
||||
@@ -78,7 +78,7 @@ public sealed class StorageTickServiceTests
|
||||
[Fact]
|
||||
public async Task Tick_ArchiveAfterDays1_ArchivesTwoDayOldCard()
|
||||
{
|
||||
(StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create();
|
||||
(StorageTickService service, TestKanjStore store, TestSettingsStore settings) = Create();
|
||||
settings.Preload(SettingsKeys.ArchiveAfterDays, "1");
|
||||
store.SeedCard(Card("l_inbox_old", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromDays(2))));
|
||||
|
||||
@@ -91,7 +91,7 @@ public sealed class StorageTickServiceTests
|
||||
[Fact]
|
||||
public async Task Tick_SecondRun_DoesNotReArchiveNorPurgeJustArchivedCards()
|
||||
{
|
||||
(StorageTickService service, FakeKanjStore store, _) = Create();
|
||||
(StorageTickService service, TestKanjStore store, _) = Create();
|
||||
store.SeedBoard(Board("b_1"));
|
||||
store.SeedCard(Card("l_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(20))));
|
||||
|
||||
@@ -109,7 +109,7 @@ public sealed class StorageTickServiceTests
|
||||
[Fact]
|
||||
public async Task Tick_PurgesOnlyArchiveCardsWithArchivedAtOlderThanArchiveClearDays()
|
||||
{
|
||||
(StorageTickService service, FakeKanjStore store, _) = Create();
|
||||
(StorageTickService service, TestKanjStore store, _) = Create();
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
store.SeedCard(Card("l_expired", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(30))));
|
||||
store.SetArchivedAt("l_expired", now.AddDays(-100)); // старше 90 дн.
|
||||
@@ -129,7 +129,7 @@ public sealed class StorageTickServiceTests
|
||||
[Fact]
|
||||
public async Task Tick_ArchiveClearDaysFromSettings_ControlsArchivePurgeBoundary()
|
||||
{
|
||||
(StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create();
|
||||
(StorageTickService service, TestKanjStore store, TestSettingsStore settings) = Create();
|
||||
settings.Preload(SettingsKeys.ArchiveClearDays, "30");
|
||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||
store.SeedCard(Card("l_expired", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(10))));
|
||||
@@ -148,7 +148,7 @@ public sealed class StorageTickServiceTests
|
||||
[Fact]
|
||||
public async Task Tick_PurgesOnlyTrashCardsWithReceivedAtOlderThanTrashClearDays()
|
||||
{
|
||||
(StorageTickService service, FakeKanjStore store, _) = Create();
|
||||
(StorageTickService service, TestKanjStore store, _) = Create();
|
||||
store.SeedCard(Card("l_old", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(10)))); // старше 7 дн.
|
||||
store.SeedCard(Card("l_fresh", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromHours(6))));
|
||||
|
||||
@@ -165,7 +165,7 @@ public sealed class StorageTickServiceTests
|
||||
[Fact]
|
||||
public async Task Tick_TrashClearDaysFromSettings_ControlsTrashPurgeBoundary()
|
||||
{
|
||||
(StorageTickService service, FakeKanjStore store, FakeSettingsStore settings) = Create();
|
||||
(StorageTickService service, TestKanjStore store, TestSettingsStore settings) = Create();
|
||||
settings.Preload(SettingsKeys.TrashClearDays, "3");
|
||||
store.SeedCard(Card("l_old", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(5)))); // старше 3 дн.
|
||||
store.SeedCard(Card("l_fresh", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(1))));
|
||||
@@ -194,11 +194,11 @@ public sealed class StorageTickServiceTests
|
||||
|
||||
// ─── Хелперы ────────────────────────────────────────────────────────────
|
||||
|
||||
private static (StorageTickService Service, FakeKanjStore Store, FakeSettingsStore Settings) Create()
|
||||
private static (StorageTickService Service, TestKanjStore Store, TestSettingsStore Settings) Create()
|
||||
{
|
||||
var store = new FakeKanjStore();
|
||||
var settings = new FakeSettingsStore();
|
||||
return (new StorageTickService(store, settings), store, settings);
|
||||
var store = new TestKanjStore();
|
||||
var settings = new TestSettingsStore();
|
||||
return (new StorageTickService(store.Store, settings.Store), store, settings);
|
||||
}
|
||||
|
||||
// Доска-колонка минимально (id/имя достаточно для списка кандидатов фейка).
|
||||
@@ -215,13 +215,13 @@ public sealed class StorageTickServiceTests
|
||||
private static long ReceivedAtMsAgo(TimeSpan age) => DateTimeOffset.UtcNow.Subtract(age).ToUnixTimeMilliseconds();
|
||||
|
||||
// Колонка карточки в фейке после тика.
|
||||
private static string ColOf(FakeKanjStore store, string cardId) =>
|
||||
private static string ColOf(TestKanjStore store, string cardId) =>
|
||||
store.CardDtos.Single(card => card.Id == cardId).Col;
|
||||
|
||||
// Флаг is_new карточки после тика.
|
||||
private static bool IsNewOf(FakeKanjStore store, string cardId) =>
|
||||
private static bool IsNewOf(TestKanjStore store, string cardId) =>
|
||||
store.CardDtos.Single(card => card.Id == cardId).IsNew;
|
||||
|
||||
private static IReadOnlyList<MatchHitDto> MatchHitsOf(FakeKanjStore store, string cardId) =>
|
||||
private static IReadOnlyList<MatchHitDto> MatchHitsOf(TestKanjStore store, string cardId) =>
|
||||
store.CardDtos.Single(card => card.Id == cardId).MatchHits;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using Deal.Modules.Cards.Application.Sources;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
using Deal.Tests.Unit.Modules.Kanban;
|
||||
using Deal.Tests.Unit.Support;
|
||||
|
||||
namespace Deal.Tests.Unit.Modules.Pipeline;
|
||||
@@ -16,8 +15,8 @@ public sealed class PipelineIngestServiceTests
|
||||
[Fact]
|
||||
public async Task EnqueueAsync_TrimsTextAndWritesNewRowWithPId()
|
||||
{
|
||||
FakePipelineStore store = new();
|
||||
PipelineIngestService service = new(store);
|
||||
TestPipelineStore store = new();
|
||||
PipelineIngestService service = new(store.Store);
|
||||
|
||||
PipelineIngestResultDto result = await service.EnqueueAsync(
|
||||
Message(
|
||||
@@ -48,8 +47,8 @@ public sealed class PipelineIngestServiceTests
|
||||
[Fact]
|
||||
public async Task EnqueueAsync_MsgAtAbsent_ZeroMsgAtAndQueuedNow()
|
||||
{
|
||||
FakePipelineStore store = new();
|
||||
PipelineIngestService service = new(store);
|
||||
TestPipelineStore store = new();
|
||||
PipelineIngestService service = new(store.Store);
|
||||
|
||||
await service.EnqueueAsync(
|
||||
Message("Сообщение без времени", receivedAt: default(DateTimeOffset)),
|
||||
@@ -63,8 +62,8 @@ public sealed class PipelineIngestServiceTests
|
||||
[Fact]
|
||||
public async Task EnqueueAsync_ForceFlag_PropagatedToQueueRow()
|
||||
{
|
||||
FakePipelineStore store = new();
|
||||
PipelineIngestService service = new(store);
|
||||
TestPipelineStore store = new();
|
||||
PipelineIngestService service = new(store.Store);
|
||||
|
||||
await service.EnqueueAsync(Message("Возврат из отсева", force: true), CancellationToken.None);
|
||||
|
||||
@@ -76,8 +75,8 @@ public sealed class PipelineIngestServiceTests
|
||||
[Fact]
|
||||
public async Task EnqueueAsync_EmptyOrWhitespaceText_Noop()
|
||||
{
|
||||
FakePipelineStore store = new();
|
||||
PipelineIngestService service = new(store);
|
||||
TestPipelineStore store = new();
|
||||
PipelineIngestService service = new(store.Store);
|
||||
|
||||
PipelineIngestResultDto empty = await service.EnqueueAsync(Message(text: string.Empty), CancellationToken.None);
|
||||
PipelineIngestResultDto spaces = await service.EnqueueAsync(Message(text: " "), CancellationToken.None);
|
||||
@@ -93,8 +92,8 @@ public sealed class PipelineIngestServiceTests
|
||||
[Fact]
|
||||
public async Task EnqueueAsync_SameSourceTwice_SecondIsDuplicate()
|
||||
{
|
||||
FakePipelineStore store = new();
|
||||
PipelineIngestService service = new(store);
|
||||
TestPipelineStore store = new();
|
||||
PipelineIngestService service = new(store.Store);
|
||||
QueuedMessage first = Message("Сообщение", origin: "d_1", externalId: "7");
|
||||
QueuedMessage second = Message("Сообщение снова", origin: "d_1", externalId: "7");
|
||||
|
||||
@@ -111,8 +110,8 @@ public sealed class PipelineIngestServiceTests
|
||||
[Fact]
|
||||
public async Task EnqueueAsync_SameSourceDifferentExternalIds_BothAccepted()
|
||||
{
|
||||
FakePipelineStore store = new();
|
||||
PipelineIngestService service = new(store);
|
||||
TestPipelineStore store = new();
|
||||
PipelineIngestService service = new(store.Store);
|
||||
|
||||
await service.EnqueueAsync(Message("Первое", origin: "d_1", externalId: "1"), CancellationToken.None);
|
||||
PipelineIngestResultDto second = await service.EnqueueAsync(Message("Второе", origin: "d_1", externalId: "2"), CancellationToken.None);
|
||||
@@ -124,8 +123,8 @@ public sealed class PipelineIngestServiceTests
|
||||
[Fact]
|
||||
public async Task EnqueueAsync_SameOriginWithoutExternalId_SecondIsDuplicate()
|
||||
{
|
||||
FakePipelineStore store = new();
|
||||
PipelineIngestService service = new(store);
|
||||
TestPipelineStore store = new();
|
||||
PipelineIngestService service = new(store.Store);
|
||||
|
||||
PipelineIngestResultDto first = await service.EnqueueAsync(
|
||||
Message("Первое", origin: "d_1", externalId: null), CancellationToken.None);
|
||||
@@ -142,8 +141,8 @@ public sealed class PipelineIngestServiceTests
|
||||
[Fact]
|
||||
public async Task EnqueueAsync_LongText_TruncatedTo6000CodePoints()
|
||||
{
|
||||
FakePipelineStore store = new();
|
||||
PipelineIngestService service = new(store);
|
||||
TestPipelineStore store = new();
|
||||
PipelineIngestService service = new(store.Store);
|
||||
string longText = new string('а', 7_000);
|
||||
|
||||
await service.EnqueueAsync(Message(longText), CancellationToken.None);
|
||||
|
||||
@@ -3,7 +3,6 @@ using Deal.Modules.Cards.Application.Sources;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
using Deal.Tests.Unit.Contracts;
|
||||
using Deal.Tests.Unit.Modules.Kanban;
|
||||
using Deal.Tests.Unit.Support;
|
||||
|
||||
namespace Deal.Tests.Unit.Modules.Pipeline;
|
||||
@@ -17,7 +16,7 @@ public sealed class PipelineProcessingServiceTests
|
||||
[Fact]
|
||||
public async Task RejectAsync_WhitespaceText_Noop()
|
||||
{
|
||||
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
|
||||
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
|
||||
|
||||
await service.RejectAsync(new RejectRecord { Text = " ", DecidedBy = "stop", Stage = "length" }, CancellationToken.None);
|
||||
|
||||
@@ -27,7 +26,7 @@ public sealed class PipelineProcessingServiceTests
|
||||
[Fact]
|
||||
public async Task RejectAsync_TruncatesFieldsDefaultsHueAndUsesDeterministicId()
|
||||
{
|
||||
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
|
||||
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
|
||||
|
||||
await service.RejectAsync(new RejectRecord
|
||||
{
|
||||
@@ -53,7 +52,7 @@ public sealed class PipelineProcessingServiceTests
|
||||
[Fact]
|
||||
public async Task RejectAsync_SameDialogAndMsgId_UpsertsSingleRow()
|
||||
{
|
||||
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
|
||||
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
|
||||
|
||||
await service.RejectAsync(Reject("d_1", 7, stage: "length", reason: "короткое"), CancellationToken.None);
|
||||
await service.RejectAsync(Reject("d_1", 7, stage: "stop", reason: "стоп-фраза «реклама»"), CancellationToken.None);
|
||||
@@ -68,7 +67,7 @@ public sealed class PipelineProcessingServiceTests
|
||||
[Fact]
|
||||
public async Task QueueCountsAsync_CountsNewAndFiltered()
|
||||
{
|
||||
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
|
||||
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
|
||||
store.SeedQueue(QueueItem("p_1", PipelineQueueStatuses.New, queuedAtMs: 1));
|
||||
store.SeedQueue(QueueItem("p_2", PipelineQueueStatuses.New, queuedAtMs: 2));
|
||||
store.SeedQueue(QueueItem("p_3", PipelineQueueStatuses.Filtered, queuedAtMs: 3));
|
||||
@@ -83,7 +82,7 @@ public sealed class PipelineProcessingServiceTests
|
||||
[Fact]
|
||||
public async Task StatsAsync_ReturnsQueueCountsAndRejectedCount()
|
||||
{
|
||||
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
|
||||
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
|
||||
store.SeedQueue(QueueItem("p_1", PipelineQueueStatuses.New, queuedAtMs: 1));
|
||||
store.SeedRejected(Rejected("r_1"));
|
||||
store.SeedRejected(Rejected("r_2"));
|
||||
@@ -99,7 +98,7 @@ public sealed class PipelineProcessingServiceTests
|
||||
[Fact]
|
||||
public async Task ListQueueAsync_OrdersByQueuedAtAndClampsLimitToMinimumOne()
|
||||
{
|
||||
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
|
||||
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
|
||||
store.SeedQueue(QueueItem("p_1", PipelineQueueStatuses.New, queuedAtMs: 30));
|
||||
store.SeedQueue(QueueItem("p_2", PipelineQueueStatuses.Filtered, queuedAtMs: 10));
|
||||
store.SeedQueue(QueueItem("p_3", PipelineQueueStatuses.New, queuedAtMs: 20));
|
||||
@@ -116,7 +115,7 @@ public sealed class PipelineProcessingServiceTests
|
||||
[Fact]
|
||||
public async Task ListRejectedAsync_NoQuery_NewestFirstWithOffsetAndLimit()
|
||||
{
|
||||
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
|
||||
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
|
||||
store.SeedRejected(Rejected("r_1", rejectedAtMs: 1_000));
|
||||
store.SeedRejected(Rejected("r_2", rejectedAtMs: 2_000));
|
||||
store.SeedRejected(Rejected("r_3", rejectedAtMs: 3_000));
|
||||
@@ -132,7 +131,7 @@ public sealed class PipelineProcessingServiceTests
|
||||
[Fact]
|
||||
public async Task ListRejectedAsync_QueryMatchesTextReasonKwAndSourceName()
|
||||
{
|
||||
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
|
||||
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
|
||||
store.SeedRejected(Rejected("r_text", text: "Middle Python разработчик", rejectedAtMs: 1_000));
|
||||
store.SeedRejected(Rejected("r_reason", reason: "стоп-фраза «реклама»", rejectedAtMs: 2_000));
|
||||
store.SeedRejected(Rejected("r_kw", kw: "спам", rejectedAtMs: 3_000));
|
||||
@@ -152,7 +151,7 @@ public sealed class PipelineProcessingServiceTests
|
||||
[Fact]
|
||||
public async Task ListRejectedAsync_QueryWithOffsetPagesOverCandidatesAndEchoesTotal()
|
||||
{
|
||||
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
|
||||
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
|
||||
store.SeedRejected(Rejected("r_1", text: "middle a", rejectedAtMs: 1_000));
|
||||
store.SeedRejected(Rejected("r_2", text: "middle b", rejectedAtMs: 2_000));
|
||||
store.SeedRejected(Rejected("r_3", text: "middle c", rejectedAtMs: 3_000));
|
||||
@@ -168,7 +167,7 @@ public sealed class PipelineProcessingServiceTests
|
||||
[Fact]
|
||||
public async Task ReturnAsync_RecordNotFound_ReturnsNull()
|
||||
{
|
||||
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
|
||||
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
|
||||
|
||||
RejectReturnResultDto? result = await service.ReturnAsync("r_missing", string.Empty, CancellationToken.None);
|
||||
|
||||
@@ -180,7 +179,7 @@ public sealed class PipelineProcessingServiceTests
|
||||
[Fact]
|
||||
public async Task ReturnAsync_AlreadyReturned_Returns400()
|
||||
{
|
||||
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
|
||||
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
|
||||
store.SeedRejected(Rejected("r_1", returned: true, returnReason: "было"));
|
||||
|
||||
RejectReturnResultDto? result = await service.ReturnAsync("r_1", "ещё раз", CancellationToken.None);
|
||||
@@ -195,7 +194,7 @@ public sealed class PipelineProcessingServiceTests
|
||||
[Fact]
|
||||
public async Task ReturnAsync_DuplicateSource_Returns400()
|
||||
{
|
||||
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
|
||||
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
|
||||
store.SeedRejected(Rejected("r_1", decidedBy: "dup", stage: "dup"));
|
||||
|
||||
RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None);
|
||||
@@ -209,7 +208,7 @@ public sealed class PipelineProcessingServiceTests
|
||||
[Fact]
|
||||
public async Task ReturnAsync_EmptyText_Returns400()
|
||||
{
|
||||
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
|
||||
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
|
||||
store.SeedRejected(Rejected("r_1", text: " "));
|
||||
|
||||
RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None);
|
||||
@@ -223,7 +222,7 @@ public sealed class PipelineProcessingServiceTests
|
||||
[Fact]
|
||||
public async Task ReturnAsync_SpamStage_UnlearnsSpamMarksRecordAndEnqueuesForceRow()
|
||||
{
|
||||
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
|
||||
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
|
||||
store.SeedRejected(Rejected(
|
||||
"r_1",
|
||||
text: " Продвижение в каналах ",
|
||||
@@ -270,7 +269,7 @@ public sealed class PipelineProcessingServiceTests
|
||||
[Fact]
|
||||
public async Task ReturnAsync_NonSpamStage_NoLearningPushButEnqueuesForceRow()
|
||||
{
|
||||
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
|
||||
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
|
||||
store.SeedRejected(Rejected(
|
||||
"r_1",
|
||||
text: "Короткое сообщение",
|
||||
@@ -296,7 +295,7 @@ public sealed class PipelineProcessingServiceTests
|
||||
[Fact]
|
||||
public async Task ReturnAsync_LongReason_TruncatedTo500CodePointsOnRecord()
|
||||
{
|
||||
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
|
||||
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
|
||||
store.SeedRejected(Rejected("r_1", text: "Текст", stage: "stale", decidedBy: "stale", origin: "d_1"));
|
||||
|
||||
await service.ReturnAsync("r_1", new string('п', 700), CancellationToken.None);
|
||||
@@ -309,7 +308,7 @@ public sealed class PipelineProcessingServiceTests
|
||||
[Fact]
|
||||
public async Task ReturnAsync_NoSourceInfo_EnqueuesDirectlyWithDefaultHue()
|
||||
{
|
||||
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
|
||||
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
|
||||
store.SeedRejected(Rejected(
|
||||
"r_old",
|
||||
text: "Старая запись без ссылки на исходное",
|
||||
@@ -340,7 +339,7 @@ public sealed class PipelineProcessingServiceTests
|
||||
[Fact]
|
||||
public async Task DeleteAsync_RemovesSingleRecord()
|
||||
{
|
||||
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
|
||||
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
|
||||
store.SeedRejected(Rejected("r_1"));
|
||||
store.SeedRejected(Rejected("r_2"));
|
||||
|
||||
@@ -352,7 +351,7 @@ public sealed class PipelineProcessingServiceTests
|
||||
[Fact]
|
||||
public async Task ClearAsync_ReturnsCountAndEmptiesRejects()
|
||||
{
|
||||
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
|
||||
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
|
||||
store.SeedRejected(Rejected("r_1"));
|
||||
store.SeedRejected(Rejected("r_2"));
|
||||
store.SeedRejected(Rejected("r_3"));
|
||||
@@ -368,7 +367,7 @@ public sealed class PipelineProcessingServiceTests
|
||||
[Fact]
|
||||
public async Task PurgeExpiredAsync_RemovesOnlyRowsOlderThanRetention()
|
||||
{
|
||||
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
|
||||
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
|
||||
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
store.SeedRejected(Rejected("r_old", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds)));
|
||||
store.SeedRejected(Rejected("r_fresh", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(1).TotalMilliseconds)));
|
||||
@@ -381,12 +380,12 @@ public sealed class PipelineProcessingServiceTests
|
||||
|
||||
// ─── Помощники ──────────────────────────────────────────────────────────
|
||||
|
||||
// Сервис на общих фейках: очередь/отсев и ML-клиент одного сценария.
|
||||
private static (PipelineProcessingService Service, FakePipelineStore Store, FakeMlClient Ml) Create()
|
||||
// Сервис на общих подставках: очередь/отсев и ML-клиент одного сценария.
|
||||
private static (PipelineProcessingService Service, TestPipelineStore Store, TestMlClient Ml) Create()
|
||||
{
|
||||
FakePipelineStore store = new();
|
||||
FakeMlClient ml = new();
|
||||
PipelineProcessingService service = new(store, ml, new PipelineIngestService(store));
|
||||
TestPipelineStore store = new();
|
||||
TestMlClient ml = new();
|
||||
PipelineProcessingService service = new(store.Store, ml.Client, new PipelineIngestService(store.Store));
|
||||
return (service, store, ml);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
|
||||
namespace Deal.Tests.Unit.Modules.Settings;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory реализация <see cref="ISettingsStore"/> для юнит-тестов SettingsService.
|
||||
/// </summary>
|
||||
public sealed class FakeSettingsStore : ISettingsStore
|
||||
{
|
||||
private readonly Dictionary<string, SettingValue> _rows = new(StringComparer.Ordinal);
|
||||
|
||||
public IReadOnlyCollection<string> Keys => _rows.Keys.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Кладёт готовую строку
|
||||
/// </summary>
|
||||
/// <param name="key">Ключ настройки.</param>
|
||||
/// <param name="valueJson">Значение, сериализованное в JSON.</param>
|
||||
public void Preload(string key, string valueJson)
|
||||
{
|
||||
_rows[key] = new SettingValue(key, valueJson, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает JSON сохранённого значения или null, если ключа нет.
|
||||
/// </summary>
|
||||
/// <param name="key">Ключ настройки.</param>
|
||||
/// <returns>JSON-строка значения или null.</returns>
|
||||
public string? GetStoredJson(string key)
|
||||
{
|
||||
return _rows.TryGetValue(key, out SettingValue? row) ? row.ValueJson : null;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SettingValue?> GetAsync(string key, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(_rows.TryGetValue(key, out SettingValue? row) ? row : null);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyCollection<SettingValue>> GetAllAsync(CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyCollection<SettingValue>>(_rows.Values.ToList());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetAsync(
|
||||
string key,
|
||||
string valueJson,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_rows[key] = new SettingValue(key, valueJson, DateTimeOffset.UtcNow);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task RemoveAsync(string key, CancellationToken ct)
|
||||
{
|
||||
_rows.Remove(key);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Deal.Tests.Unit.Support;
|
||||
|
||||
namespace Deal.Tests.Unit.Modules.Tenants;
|
||||
|
||||
@@ -14,8 +15,8 @@ public sealed class AuditServiceTests
|
||||
[Fact]
|
||||
public async Task AppendAsync_SetsAtToUtcNow_AndSavesAllFields()
|
||||
{
|
||||
var store = new FakeAuditLogStore();
|
||||
var service = new AuditService(store);
|
||||
var store = new TestAuditLogStore();
|
||||
var service = new AuditService(store.Store);
|
||||
var actorId = Guid.NewGuid();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var before = DateTimeOffset.UtcNow;
|
||||
@@ -43,11 +44,11 @@ public sealed class AuditServiceTests
|
||||
[Fact]
|
||||
public async Task QueryAsync_ReturnsFilteredRecordsNewestFirst()
|
||||
{
|
||||
var store = new FakeAuditLogStore();
|
||||
var store = new TestAuditLogStore();
|
||||
await AppendWithAtAsync(store, AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, tenantId: null, DateTimeOffset.UtcNow.AddMinutes(-2));
|
||||
await AppendWithAtAsync(store, AuditEvents.TenantLoginFailed, AuditActorTypes.Tenant, tenantId: null, DateTimeOffset.UtcNow.AddMinutes(-1));
|
||||
await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId: null, DateTimeOffset.UtcNow);
|
||||
var service = new AuditService(store);
|
||||
var service = new AuditService(store.Store);
|
||||
|
||||
var all = await service.QueryAsync(EmptyFilter(limit: 100), CancellationToken.None);
|
||||
Assert.Equal(3, all.Count);
|
||||
@@ -71,7 +72,7 @@ public sealed class AuditServiceTests
|
||||
[Fact]
|
||||
public async Task QueryAsync_AppliesAtRangeAndTenantFilters()
|
||||
{
|
||||
var store = new FakeAuditLogStore();
|
||||
var store = new TestAuditLogStore();
|
||||
var tenantId = Guid.NewGuid();
|
||||
var outsideTenantId = Guid.NewGuid();
|
||||
var from = DateTimeOffset.UtcNow.AddMinutes(-10);
|
||||
@@ -79,7 +80,7 @@ public sealed class AuditServiceTests
|
||||
await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId, from.AddMinutes(-1)); // вне окна
|
||||
await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId, from);
|
||||
await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, outsideTenantId, to);
|
||||
var service = new AuditService(store);
|
||||
var service = new AuditService(store.Store);
|
||||
|
||||
var byRange = await service.QueryAsync(
|
||||
new AuditQueryDto(AuditEvents.TenantLoginOk, null, null, from, to, 100), CancellationToken.None);
|
||||
@@ -94,13 +95,13 @@ public sealed class AuditServiceTests
|
||||
[Fact]
|
||||
public async Task CountAsync_CountsAllRowsMatchingFilterRegardlessOfLimit()
|
||||
{
|
||||
var store = new FakeAuditLogStore();
|
||||
var store = new TestAuditLogStore();
|
||||
for (int i = 0; i < 7; i++)
|
||||
{
|
||||
await AppendWithAtAsync(store, AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, tenantId: null, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
var service = new AuditService(store);
|
||||
var service = new AuditService(store.Store);
|
||||
var filter = new AuditQueryDto(AuditEvents.OperatorLoginOk, null, null, null, null, Limit: 2);
|
||||
|
||||
Assert.Equal(7, await service.CountAsync(filter, CancellationToken.None));
|
||||
@@ -163,13 +164,13 @@ public sealed class AuditServiceTests
|
||||
|
||||
// Добавляет запись в фейк с явным At/актором (минуя сервис — для проверок сортировки/фильтров).
|
||||
private static async Task AppendWithAtAsync(
|
||||
FakeAuditLogStore store,
|
||||
TestAuditLogStore store,
|
||||
string eventType,
|
||||
string actorType,
|
||||
Guid? tenantId,
|
||||
DateTimeOffset at)
|
||||
{
|
||||
await store.AppendAsync(new AuditRecordDto(
|
||||
await store.Store.AppendAsync(new AuditRecordDto(
|
||||
eventType,
|
||||
actorType,
|
||||
ActorId: null,
|
||||
|
||||
@@ -5,7 +5,7 @@ using Deal.Tests.Unit.Support;
|
||||
namespace Deal.Tests.Unit.Modules.Tenants;
|
||||
|
||||
/// <summary>
|
||||
/// Юнит-тесты AuthService на fake-хранилище и детерминированном «хэшере».
|
||||
/// Юнит-тесты AuthService на подставке хранилища и детерминированном «хэшере».
|
||||
/// </summary>
|
||||
public sealed class AuthServiceTests
|
||||
{
|
||||
@@ -17,17 +17,17 @@ public sealed class AuthServiceTests
|
||||
private static readonly Guid UserId = Guid.NewGuid();
|
||||
private static readonly Guid UserTenantId = Guid.NewGuid();
|
||||
|
||||
private readonly FakeAuthStore _store;
|
||||
private readonly TestAuthStore _store;
|
||||
private readonly TestTenantStore _tenantStore;
|
||||
private readonly AuthService _service;
|
||||
|
||||
public AuthServiceTests()
|
||||
{
|
||||
var passwordHasher = TestHashers.New();
|
||||
_store = new FakeAuthStore();
|
||||
_store = new TestAuthStore();
|
||||
_tenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Тестовый тенант", TenantStatuses.Active, DateTimeOffset.UtcNow));
|
||||
_store.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword)));
|
||||
_service = new AuthService(_store, passwordHasher, _tenantStore.Repository);
|
||||
_service = new AuthService(_store.Store, passwordHasher, _tenantStore.Repository);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -133,7 +133,7 @@ public sealed class AuthServiceTests
|
||||
var user = await _service.ResolveSessionAsync(rawToken, CancellationToken.None);
|
||||
|
||||
Assert.Null(user);
|
||||
// Очистка «протухших» сессий вызвана и удалила сессию из fake-хранилища.
|
||||
// Очистка «протухших» сессий вызвана и удалила сессию из хранилища.
|
||||
Assert.Contains("delete-expired", _store.Calls);
|
||||
Assert.Empty(_store.Sessions);
|
||||
}
|
||||
@@ -204,10 +204,10 @@ public sealed class AuthServiceTests
|
||||
public async Task LoginAsync_WhenTenantSuspended_ReturnsSuspendedErrorWithoutSession()
|
||||
{
|
||||
var passwordHasher = TestHashers.New();
|
||||
var suspendedStore = new FakeAuthStore();
|
||||
var suspendedStore = new TestAuthStore();
|
||||
suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword)));
|
||||
var suspendedTenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Приостановленный", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
|
||||
var suspendedService = new AuthService(suspendedStore, passwordHasher, suspendedTenantStore.Repository);
|
||||
var suspendedService = new AuthService(suspendedStore.Store, passwordHasher, suspendedTenantStore.Repository);
|
||||
|
||||
var result = await suspendedService.LoginAsync(UserLogin, UserPassword, CancellationToken.None);
|
||||
|
||||
@@ -225,10 +225,10 @@ public sealed class AuthServiceTests
|
||||
// Порядок проверок AuthService: учётные данные раньше статуса тенанта — неверный пароль не раскрывает
|
||||
// приостановку (тот же ответ, что для активного тенанта: Login/Token/Error пусты).
|
||||
var passwordHasher = TestHashers.New();
|
||||
var suspendedStore = new FakeAuthStore();
|
||||
var suspendedStore = new TestAuthStore();
|
||||
suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword)));
|
||||
var suspendedTenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Приостановленный", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
|
||||
var suspendedService = new AuthService(suspendedStore, passwordHasher, suspendedTenantStore.Repository);
|
||||
var suspendedService = new AuthService(suspendedStore.Store, passwordHasher, suspendedTenantStore.Repository);
|
||||
|
||||
var result = await suspendedService.LoginAsync(UserLogin, "wrong-password", CancellationToken.None);
|
||||
|
||||
@@ -266,12 +266,12 @@ public sealed class AuthServiceTests
|
||||
public async Task ImpersonateAsync_WithoutLogin_TakesFirstTenantUser()
|
||||
{
|
||||
var passwordHasher = TestHashers.New();
|
||||
var multiUserStore = new FakeAuthStore();
|
||||
var multiUserStore = new TestAuthStore();
|
||||
var firstUserId = Guid.NewGuid();
|
||||
var secondUserId = Guid.NewGuid();
|
||||
multiUserStore.AddUser(new StoredUserDto(firstUserId, "first@example.com", UserTenantId, "active", passwordHasher.Hash("p1")));
|
||||
multiUserStore.AddUser(new StoredUserDto(secondUserId, "second@example.com", UserTenantId, "active", passwordHasher.Hash("p2")));
|
||||
var multiUserService = new AuthService(multiUserStore, passwordHasher, _tenantStore.Repository);
|
||||
var multiUserService = new AuthService(multiUserStore.Store, passwordHasher, _tenantStore.Repository);
|
||||
|
||||
// login не задан — берётся первый пользователь тенанта (порядок хранилища = CreatedAt в EF-адаптере).
|
||||
var result = await multiUserService.ImpersonateAsync(UserTenantId, targetLogin: null, Guid.NewGuid(), CancellationToken.None);
|
||||
@@ -294,9 +294,9 @@ public sealed class AuthServiceTests
|
||||
|
||||
// Пользователь другого тенанта с тем же логином тоже не резолвится (не раскрываем существование логина).
|
||||
var passwordHasher = TestHashers.New();
|
||||
var crossStore = new FakeAuthStore();
|
||||
var crossStore = new TestAuthStore();
|
||||
crossStore.AddUser(new StoredUserDto(Guid.NewGuid(), "cross@example.com", otherTenantId, "active", passwordHasher.Hash("p")));
|
||||
var crossService = new AuthService(crossStore, passwordHasher, _tenantStore.Repository);
|
||||
var crossService = new AuthService(crossStore.Store, passwordHasher, _tenantStore.Repository);
|
||||
var cross = await crossService.ImpersonateAsync(UserTenantId, "cross@example.com", Guid.NewGuid(), CancellationToken.None);
|
||||
|
||||
Assert.False(cross.Ok);
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
|
||||
namespace Deal.Tests.Unit.Modules.Tenants;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory реализация <see cref="IAuditLogStore"/> для юнит/HTTP-тестов аудита.
|
||||
/// </summary>
|
||||
public sealed class FakeAuditLogStore : IAuditLogStore
|
||||
{
|
||||
private readonly List<AuditRecordDto> _records = [];
|
||||
|
||||
/// <summary>
|
||||
/// Записи хранилища в порядке добавления
|
||||
/// </summary>
|
||||
public IReadOnlyList<AuditRecordDto> Records => _records;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AppendAsync(AuditRecordDto record, CancellationToken ct)
|
||||
{
|
||||
_records.Add(record with { Id = _records.Count + 1 });
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AuditRecordDto>> QueryAsync(AuditQueryDto filter, CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<AuditRecordDto> result = ApplyFilters(filter)
|
||||
.OrderByDescending(r => r.At)
|
||||
.Skip(Math.Max(0, filter.Offset))
|
||||
.Take(Math.Max(1, Math.Min(AuditService.MaxQueryLimit, filter.Limit)))
|
||||
.ToList();
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CountAsync(AuditQueryDto filter, CancellationToken ct) =>
|
||||
Task.FromResult(ApplyFilters(filter).Count());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> PurgeOlderThanAsync(DateTimeOffset cutoff, CancellationToken ct)
|
||||
{
|
||||
int removed = _records.RemoveAll(record => record.At < cutoff);
|
||||
return Task.FromResult(removed);
|
||||
}
|
||||
|
||||
private IEnumerable<AuditRecordDto> ApplyFilters(AuditQueryDto filter) =>
|
||||
_records.Where(r =>
|
||||
(filter.EventType is null || r.EventType == filter.EventType) &&
|
||||
(filter.ActorType is null || r.ActorType == filter.ActorType) &&
|
||||
(filter.TenantId is null || r.TenantId == filter.TenantId) &&
|
||||
(filter.ActorId is null || r.ActorId == filter.ActorId) &&
|
||||
(filter.From is null || r.At >= filter.From.Value) &&
|
||||
(filter.To is null || r.At <= filter.To.Value));
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Tests.Unit.Modules.Tenants;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory реализация <see cref="IAuthStore"/> для юнит-тестов AuthService.
|
||||
/// </summary>
|
||||
public sealed class FakeAuthStore : IAuthStore
|
||||
{
|
||||
private readonly List<StoredUserDto> _users = [];
|
||||
private readonly List<SessionDto> _sessions = [];
|
||||
private readonly List<string> _calls = [];
|
||||
|
||||
/// <summary>
|
||||
/// Пользователи хранилища.
|
||||
/// </summary>
|
||||
public IReadOnlyList<StoredUserDto> Users => _users;
|
||||
|
||||
/// <summary>
|
||||
/// Сессии хранилища.
|
||||
/// </summary>
|
||||
public IReadOnlyList<SessionDto> Sessions => _sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Журнал мутирующих вызовов в порядке их совершения.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Calls => _calls;
|
||||
|
||||
/// <summary>
|
||||
/// Добавляет пользователя
|
||||
/// </summary>
|
||||
/// <param name="user">Пользователь.</param>
|
||||
public void AddUser(StoredUserDto user) => _users.Add(user);
|
||||
|
||||
/// <summary>
|
||||
/// Добавляет сессию
|
||||
/// </summary>
|
||||
/// <param name="session">Сессия.</param>
|
||||
public void AddSession(SessionDto session) => _sessions.Add(session);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<StoredUserDto?> FindUserByLoginAsync(string login, CancellationToken ct) =>
|
||||
Task.FromResult(_users.SingleOrDefault(u => u.Login == login));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<SessionDto?> FindSessionByTokenHashAsync(string tokenHash, CancellationToken ct) =>
|
||||
Task.FromResult(_sessions.SingleOrDefault(s => s.TokenHash == tokenHash));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<UserIdentityDto?> FindUserByIdAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
var user = _users.SingleOrDefault(u => u.Id == userId);
|
||||
return Task.FromResult(
|
||||
user is null
|
||||
? null
|
||||
: new UserIdentityDto(user.Id, user.Login, user.TenantId, user.Status));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<UserIdentityDto>> ListUsersByTenantIdAsync(Guid tenantId, CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<UserIdentityDto>>(_users
|
||||
.Where(u => u.TenantId == tenantId)
|
||||
.Select(u => new UserIdentityDto(u.Id, u.Login, u.TenantId, u.Status))
|
||||
.ToList());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task CreateUserAsync(StoredUserDto user, CancellationToken ct)
|
||||
{
|
||||
_users.Add(user);
|
||||
_calls.Add($"create-user:{user.Id}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task CreateSessionAsync(SessionDto session, CancellationToken ct)
|
||||
{
|
||||
_sessions.Add(session);
|
||||
_calls.Add($"create-session:{session.UserId}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteSessionAsync(string tokenHash, CancellationToken ct)
|
||||
{
|
||||
_sessions.RemoveAll(s => s.TokenHash == tokenHash);
|
||||
_calls.Add($"delete-session:{tokenHash}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteSessionsByUserIdAsync(Guid userId, CancellationToken ct)
|
||||
{
|
||||
_sessions.RemoveAll(s => s.UserId == userId);
|
||||
_calls.Add($"delete-user-sessions:{userId}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdatePasswordHashAsync(
|
||||
Guid userId,
|
||||
string passwordHash,
|
||||
CancellationToken ct)
|
||||
{
|
||||
int index = _users.FindIndex(u => u.Id == userId);
|
||||
if (index >= 0)
|
||||
{
|
||||
var user = _users[index];
|
||||
_users[index] = user with { PasswordHash = passwordHash };
|
||||
}
|
||||
|
||||
_calls.Add($"update-password-hash:{userId}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteExpiredSessionsAsync(CancellationToken ct)
|
||||
{
|
||||
_sessions.RemoveAll(s => s.ExpiresAt <= DateTimeOffset.UtcNow);
|
||||
_calls.Add("delete-expired");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Tests.Unit.Modules.Tenants;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory реализация <see cref="IOperatorAuthStore"/> для юнит-тестов OperatorAuthService.
|
||||
/// </summary>
|
||||
public sealed class FakeOperatorAuthStore : IOperatorAuthStore
|
||||
{
|
||||
private readonly List<StoredOperatorDto> _operators = [];
|
||||
private readonly List<OperatorSessionDto> _sessions = [];
|
||||
private readonly List<string> _calls = [];
|
||||
|
||||
/// <summary>
|
||||
/// Операторы хранилища.
|
||||
/// </summary>
|
||||
public IReadOnlyList<StoredOperatorDto> Operators => _operators;
|
||||
|
||||
/// <summary>
|
||||
/// Сессии операторов хранилища.
|
||||
/// </summary>
|
||||
public IReadOnlyList<OperatorSessionDto> Sessions => _sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Журнал мутирующих вызовов в порядке их совершения.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Calls => _calls;
|
||||
|
||||
/// <summary>
|
||||
/// Добавляет оператора
|
||||
/// </summary>
|
||||
/// <param name="operatorRecord">Оператор.</param>
|
||||
public void AddOperator(StoredOperatorDto operatorRecord) => _operators.Add(operatorRecord);
|
||||
|
||||
/// <summary>
|
||||
/// Добавляет сессию
|
||||
/// </summary>
|
||||
/// <param name="session">Сессия.</param>
|
||||
public void AddSession(OperatorSessionDto session) => _sessions.Add(session);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<StoredOperatorDto?> FindByLoginAsync(string login, CancellationToken ct) =>
|
||||
Task.FromResult(_operators.SingleOrDefault(o => o.Login == login));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<OperatorSessionDto?> FindSessionByTokenHashAsync(string tokenHash, CancellationToken ct) =>
|
||||
Task.FromResult(_sessions.SingleOrDefault(s => s.TokenHash == tokenHash));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task CreateAsync(StoredOperatorDto operatorRecord, CancellationToken ct)
|
||||
{
|
||||
_operators.Add(operatorRecord);
|
||||
_calls.Add($"create-operator:{operatorRecord.Id}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task CreateSessionAsync(OperatorSessionDto session, CancellationToken ct)
|
||||
{
|
||||
_sessions.Add(session);
|
||||
_calls.Add($"create-session:{session.OperatorId}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteSessionAsync(string tokenHash, CancellationToken ct)
|
||||
{
|
||||
_sessions.RemoveAll(s => s.TokenHash == tokenHash);
|
||||
_calls.Add($"delete-session:{tokenHash}");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteExpiredSessionsAsync(CancellationToken ct)
|
||||
{
|
||||
_sessions.RemoveAll(s => s.ExpiresAt <= DateTimeOffset.UtcNow);
|
||||
_calls.Add("delete-expired");
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Tests.Unit.Modules.Tenants;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory реализация <see cref="ITenantRepository"/> для unit-тестов
|
||||
/// </summary>
|
||||
public sealed class FakeTenantRepository : ITenantRepository
|
||||
{
|
||||
private readonly IReadOnlyList<TenantRecordDto> _tenants;
|
||||
private readonly List<(int Offset, int Limit)> _pageRequests = [];
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт реестр с фиксированным списком тенантов.
|
||||
/// </summary>
|
||||
/// <param name="tenants">Тенанты реестра (как строки public.tenants).</param>
|
||||
public FakeTenantRepository(params TenantRecordDto[] tenants)
|
||||
{
|
||||
_tenants = tenants.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Запрошенные страницы (offset, limit) в порядке обращений.
|
||||
/// </summary>
|
||||
public IReadOnlyList<(int Offset, int Limit)> PageRequests => _pageRequests;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TenantRecordDto>> ListAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<TenantRecordDto>>(_tenants.ToList());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TenantRecordDto>> ListPageAsync(
|
||||
int offset,
|
||||
int limit,
|
||||
CancellationToken ct)
|
||||
{
|
||||
_pageRequests.Add((offset, limit));
|
||||
IReadOnlyList<TenantRecordDto> page = _tenants.Skip(offset).Take(limit).ToList();
|
||||
return Task.FromResult(page);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantRecordDto?> FindByIdAsync(Guid id, CancellationToken ct) =>
|
||||
throw new NotSupportedException("FindByIdAsync не используется тестами реестра");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> UpdateStatusAsync(
|
||||
Guid id,
|
||||
string status,
|
||||
CancellationToken ct) =>
|
||||
throw new NotSupportedException("UpdateStatusAsync не используется тестами реестра");
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task CreateAsync(TenantRecordDto tenant, CancellationToken ct) =>
|
||||
throw new NotSupportedException("CreateAsync не используется тестами реестра");
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Tests.Unit.Modules.Tenants;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory реализация <see cref="ITenantRepository"/> с поддержкой создания тенантов.
|
||||
/// </summary>
|
||||
public sealed class FakeTenantStore : ITenantRepository
|
||||
{
|
||||
private readonly List<TenantRecordDto> _tenants;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт хранилище, опционально с предзаполненным реестром тенантов.
|
||||
/// </summary>
|
||||
/// <param name="seed">Тенанты, которые уже есть в реестре (как строки public.tenants).</param>
|
||||
public FakeTenantStore(params TenantRecordDto[] seed)
|
||||
{
|
||||
_tenants = seed.ToList();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Записи реестра в порядке добавления.
|
||||
/// </summary>
|
||||
public IReadOnlyList<TenantRecordDto> Tenants => _tenants;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantRecordDto?> FindByIdAsync(Guid id, CancellationToken ct) =>
|
||||
Task.FromResult(_tenants.FirstOrDefault(tenant => tenant.Id == id));
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task CreateAsync(TenantRecordDto tenant, CancellationToken ct)
|
||||
{
|
||||
_tenants.Add(tenant);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TenantRecordDto>> ListAsync(CancellationToken ct) =>
|
||||
Task.FromResult<IReadOnlyList<TenantRecordDto>>(_tenants.ToList());
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TenantRecordDto>> ListPageAsync(
|
||||
int offset,
|
||||
int limit,
|
||||
CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<TenantRecordDto> page = _tenants.Skip(offset).Take(limit).ToList();
|
||||
return Task.FromResult(page);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> UpdateStatusAsync(
|
||||
Guid id,
|
||||
string status,
|
||||
CancellationToken ct)
|
||||
{
|
||||
int index = _tenants.FindIndex(tenant => tenant.Id == id);
|
||||
if (index < 0)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
}
|
||||
|
||||
var current = _tenants[index];
|
||||
_tenants[index] = current with { Status = status };
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
|
||||
namespace Deal.Tests.Unit.Modules.Tenants;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory реализация <see cref="ITokenUsageEventStore"/> для юнит/HTTP-тестов.
|
||||
/// </summary>
|
||||
public sealed class FakeTokenUsageEventStore : ITokenUsageEventStore
|
||||
{
|
||||
private readonly List<TokenUsageEventDto> _records = [];
|
||||
|
||||
/// <summary>
|
||||
/// Записи хранилища в порядке добавления.
|
||||
/// </summary>
|
||||
public IReadOnlyList<TokenUsageEventDto> Records => _records;
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AppendAsync(TokenUsageEventDto record, CancellationToken ct)
|
||||
{
|
||||
_records.Add(record);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<TokenUsageAggregateDto>> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct)
|
||||
{
|
||||
IEnumerable<TokenUsageEventDto> filtered = _records.Where(record =>
|
||||
(query.TenantId is null || record.TenantId == query.TenantId) &&
|
||||
(string.IsNullOrWhiteSpace(query.Provider) || record.Provider == query.Provider) &&
|
||||
(string.IsNullOrWhiteSpace(query.Model) || record.Model == query.Model) &&
|
||||
(string.IsNullOrWhiteSpace(query.Kind) || record.Kind == query.Kind) &&
|
||||
(query.From is null || record.At >= query.From.Value) &&
|
||||
(query.To is null || record.At <= query.To.Value));
|
||||
|
||||
IReadOnlyList<TokenUsageAggregateDto> result = query.GroupBy switch
|
||||
{
|
||||
TokenUsageGroupBys.Day => GroupByDay(filtered),
|
||||
TokenUsageGroupBys.Tenant => GroupByString(filtered, record => record.TenantId.ToString("D")),
|
||||
TokenUsageGroupBys.Provider => GroupByString(filtered, record => record.Provider),
|
||||
TokenUsageGroupBys.Model => GroupByString(filtered, record => record.Model),
|
||||
_ => throw new ArgumentException($"Неизвестная группировка: '{query.GroupBy}'.", nameof(query)),
|
||||
};
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
private static IReadOnlyList<TokenUsageAggregateDto> GroupByDay(IEnumerable<TokenUsageEventDto> source) =>
|
||||
source
|
||||
.GroupBy(record => record.At.UtcDateTime.Date)
|
||||
.OrderBy(group => group.Key)
|
||||
.Select(group => ToAggregate(group.Key.ToString("yyyy-MM-dd"), group))
|
||||
.ToList();
|
||||
|
||||
private static IReadOnlyList<TokenUsageAggregateDto> GroupByString(IEnumerable<TokenUsageEventDto> source, Func<TokenUsageEventDto, string> keySelector) =>
|
||||
source
|
||||
.GroupBy(keySelector)
|
||||
.Select(group => ToAggregate(group.Key, group))
|
||||
.OrderByDescending(row => row.TotalTokens)
|
||||
.ToList();
|
||||
|
||||
private static TokenUsageAggregateDto ToAggregate(string key, IEnumerable<TokenUsageEventDto> group)
|
||||
{
|
||||
List<TokenUsageEventDto> rows = group.ToList();
|
||||
return new TokenUsageAggregateDto(
|
||||
key,
|
||||
rows.Sum(row => row.PromptTokens),
|
||||
rows.Sum(row => row.CompletionTokens),
|
||||
rows.Sum(row => row.TotalTokens),
|
||||
rows.Count);
|
||||
}
|
||||
}
|
||||
@@ -29,7 +29,7 @@ public sealed class JoinFlowTests
|
||||
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
|
||||
var provisioner = new TestTenantProvisioner();
|
||||
var tenantStore = new TestTenantStore();
|
||||
var authStore = new FakeAuthStore();
|
||||
var authStore = new TestAuthStore();
|
||||
var passwordHasher = TestHashers.New();
|
||||
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, passwordHasher);
|
||||
|
||||
@@ -70,7 +70,7 @@ public sealed class JoinFlowTests
|
||||
var inviteStore = new TestInviteStore();
|
||||
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
|
||||
var tenantStore = new TestTenantStore();
|
||||
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, new FakeAuthStore(), TestHashers.New());
|
||||
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, new TestAuthStore(), TestHashers.New());
|
||||
|
||||
var result = await service.ActivateAsync(Code, Email, name: " ", Password, CancellationToken.None);
|
||||
|
||||
@@ -87,7 +87,7 @@ public sealed class JoinFlowTests
|
||||
var provisioner = new TestTenantProvisioner();
|
||||
// Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему.
|
||||
var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow));
|
||||
var authStore = new FakeAuthStore();
|
||||
var authStore = new TestAuthStore();
|
||||
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
|
||||
|
||||
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
|
||||
@@ -108,7 +108,7 @@ public sealed class JoinFlowTests
|
||||
var inviteStore = new TestInviteStore();
|
||||
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId));
|
||||
var tenantStore = new TestTenantStore(); // целевого тенанта в реестре нет — «битый» инвайт
|
||||
var authStore = new FakeAuthStore();
|
||||
var authStore = new TestAuthStore();
|
||||
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
|
||||
|
||||
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
|
||||
@@ -126,7 +126,7 @@ public sealed class JoinFlowTests
|
||||
var inviteStore = new TestInviteStore();
|
||||
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId));
|
||||
var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Suspended", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
|
||||
var authStore = new FakeAuthStore();
|
||||
var authStore = new TestAuthStore();
|
||||
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
|
||||
|
||||
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
|
||||
@@ -142,7 +142,7 @@ public sealed class JoinFlowTests
|
||||
{
|
||||
var inviteStore = new TestInviteStore();
|
||||
var tenantStore = new TestTenantStore();
|
||||
var authStore = new FakeAuthStore();
|
||||
var authStore = new TestAuthStore();
|
||||
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
|
||||
|
||||
var result = await service.ActivateAsync(WrongCode, Email, name: null, Password, CancellationToken.None);
|
||||
@@ -159,7 +159,7 @@ public sealed class JoinFlowTests
|
||||
var inviteStore = new TestInviteStore();
|
||||
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, expiresAt: DateTimeOffset.UtcNow.AddHours(-1)));
|
||||
var tenantStore = new TestTenantStore();
|
||||
var authStore = new FakeAuthStore();
|
||||
var authStore = new TestAuthStore();
|
||||
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
|
||||
|
||||
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
|
||||
@@ -176,7 +176,7 @@ public sealed class JoinFlowTests
|
||||
{
|
||||
var inviteStore = new TestInviteStore();
|
||||
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Activated));
|
||||
var service = NewService(inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), TestHashers.New());
|
||||
var service = NewService(inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new TestAuthStore(), TestHashers.New());
|
||||
|
||||
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
|
||||
|
||||
@@ -189,7 +189,7 @@ public sealed class JoinFlowTests
|
||||
{
|
||||
var inviteStore = new TestInviteStore();
|
||||
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Revoked));
|
||||
var service = NewService(inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), TestHashers.New());
|
||||
var service = NewService(inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new TestAuthStore(), TestHashers.New());
|
||||
|
||||
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
|
||||
|
||||
@@ -203,7 +203,7 @@ public sealed class JoinFlowTests
|
||||
var inviteStore = new TestInviteStore();
|
||||
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
|
||||
var tenantStore = new TestTenantStore();
|
||||
var authStore = new FakeAuthStore();
|
||||
var authStore = new TestAuthStore();
|
||||
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
|
||||
|
||||
var result = await service.ActivateAsync(Code, OtherEmail, name: null, Password, CancellationToken.None);
|
||||
@@ -221,7 +221,7 @@ public sealed class JoinFlowTests
|
||||
var inviteStore = new TestInviteStore();
|
||||
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
|
||||
var tenantStore = new TestTenantStore();
|
||||
var authStore = new FakeAuthStore();
|
||||
var authStore = new TestAuthStore();
|
||||
authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: StatusActive, "hash"));
|
||||
var provisioner = new TestTenantProvisioner();
|
||||
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
|
||||
@@ -248,7 +248,7 @@ public sealed class JoinFlowTests
|
||||
var inviteStore = new TestInviteStore();
|
||||
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
|
||||
var tenantStore = new TestTenantStore();
|
||||
var authStore = new FakeAuthStore();
|
||||
var authStore = new TestAuthStore();
|
||||
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
|
||||
|
||||
var result = await service.ActivateAsync(Code, Email, name: null, password, CancellationToken.None);
|
||||
@@ -267,7 +267,7 @@ public sealed class JoinFlowTests
|
||||
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
|
||||
var provisioner = new TestTenantProvisioner();
|
||||
var tenantStore = new TestTenantStore();
|
||||
var authStore = new FakeAuthStore();
|
||||
var authStore = new TestAuthStore();
|
||||
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
|
||||
|
||||
JoinResultDto first = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
|
||||
@@ -288,7 +288,7 @@ public sealed class JoinFlowTests
|
||||
// Конкурент (отзыв) «успел» до нашего CAS — приглашение уже отозвано, активация не выполнится.
|
||||
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Revoked));
|
||||
var tenantStore = new TestTenantStore();
|
||||
var authStore = new FakeAuthStore();
|
||||
var authStore = new TestAuthStore();
|
||||
var provisioner = new TestTenantProvisioner();
|
||||
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
|
||||
|
||||
@@ -311,7 +311,7 @@ public sealed class JoinFlowTests
|
||||
// Конкурентная активация «успела» до нашего CAS — приглашение уже активировано.
|
||||
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Activated));
|
||||
var tenantStore = new TestTenantStore();
|
||||
var authStore = new FakeAuthStore();
|
||||
var authStore = new TestAuthStore();
|
||||
var provisioner = new TestTenantProvisioner();
|
||||
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
|
||||
|
||||
@@ -353,17 +353,17 @@ public sealed class JoinFlowTests
|
||||
|
||||
// ─── Хелперы ─────────────────────────────────────────────────────────
|
||||
|
||||
// Собирает JoinService на общих фейк-хранилищах (все состояния — свежие).
|
||||
// Собирает JoinService на общих подставках хранилищ (все состояния — свежие).
|
||||
private static JoinService NewService(
|
||||
TestInviteStore inviteStore,
|
||||
TestTenantStore tenantStore,
|
||||
ITenantProvisioner provisioner,
|
||||
FakeAuthStore authStore,
|
||||
TestAuthStore authStore,
|
||||
IPasswordHasher passwordHasher) =>
|
||||
new(
|
||||
new InvitesService(inviteStore.Store),
|
||||
new TenantService(tenantStore.Repository, provisioner),
|
||||
authStore,
|
||||
authStore.Store,
|
||||
passwordHasher,
|
||||
tenantStore.Repository);
|
||||
|
||||
|
||||
@@ -14,15 +14,15 @@ public sealed class OperatorAuthServiceTests
|
||||
|
||||
private static readonly Guid OperatorId = Guid.NewGuid();
|
||||
|
||||
private readonly FakeOperatorAuthStore _store;
|
||||
private readonly TestOperatorAuthStore _store;
|
||||
private readonly OperatorAuthService _service;
|
||||
|
||||
public OperatorAuthServiceTests()
|
||||
{
|
||||
var passwordHasher = TestHashers.New();
|
||||
_store = new FakeOperatorAuthStore();
|
||||
_store = new TestOperatorAuthStore();
|
||||
_store.AddOperator(new StoredOperatorDto(OperatorId, OperatorLogin, "active", passwordHasher.Hash(OperatorPassword)));
|
||||
_service = new OperatorAuthService(_store, passwordHasher);
|
||||
_service = new OperatorAuthService(_store.Store, passwordHasher);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -10,15 +10,15 @@ namespace Deal.Tests.Unit.Modules.Tenants;
|
||||
/// </summary>
|
||||
public sealed class OperatorBootstrapServiceTests
|
||||
{
|
||||
private readonly FakeOperatorAuthStore _store;
|
||||
private readonly TestOperatorAuthStore _store;
|
||||
private readonly IPasswordHasher _passwordHasher;
|
||||
private readonly OperatorBootstrapService _service;
|
||||
|
||||
public OperatorBootstrapServiceTests()
|
||||
{
|
||||
_passwordHasher = TestHashers.New();
|
||||
_store = new FakeOperatorAuthStore();
|
||||
_service = new OperatorBootstrapService(_store, _passwordHasher);
|
||||
_store = new TestOperatorAuthStore();
|
||||
_service = new OperatorBootstrapService(_store.Store, _passwordHasher);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Deal.Tests.Unit.Support;
|
||||
|
||||
namespace Deal.Tests.Unit.Modules.Tenants;
|
||||
|
||||
@@ -14,7 +15,7 @@ public sealed class SuspiciousActivityServiceTests
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_NoRecords_ReturnsEmpty()
|
||||
{
|
||||
SuspiciousActivityService service = Create(new FakeAuditLogStore());
|
||||
SuspiciousActivityService service = Create(new TestAuditLogStore());
|
||||
|
||||
SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None);
|
||||
|
||||
@@ -26,7 +27,7 @@ public sealed class SuspiciousActivityServiceTests
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_FailedLoginsPerIp_TriggersAtThreshold()
|
||||
{
|
||||
var store = new FakeAuditLogStore();
|
||||
var store = new TestAuditLogStore();
|
||||
for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerIpThreshold; i++)
|
||||
{
|
||||
SeedFailed(store, ip: "10.0.0.1", login: "user", minutesAgo: i);
|
||||
@@ -46,7 +47,7 @@ public sealed class SuspiciousActivityServiceTests
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_FailedLoginsPerIp_DoubleThreshold_IsHigh()
|
||||
{
|
||||
var store = new FakeAuditLogStore();
|
||||
var store = new TestAuditLogStore();
|
||||
int count = SuspiciousActivityService.FailedLoginsPerIpThreshold * 2;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
@@ -66,7 +67,7 @@ public sealed class SuspiciousActivityServiceTests
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_FailedLoginsPerLogin_Triggers()
|
||||
{
|
||||
var store = new FakeAuditLogStore();
|
||||
var store = new TestAuditLogStore();
|
||||
for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerLoginThreshold; i++)
|
||||
{
|
||||
SeedFailed(store, ip: $"10.0.0.{i + 1}", login: "target", minutesAgo: i);
|
||||
@@ -84,11 +85,11 @@ public sealed class SuspiciousActivityServiceTests
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_ManyIpsPerActor_Triggers()
|
||||
{
|
||||
var store = new FakeAuditLogStore();
|
||||
var store = new TestAuditLogStore();
|
||||
Guid actor = Guid.NewGuid();
|
||||
for (int i = 0; i < SuspiciousActivityService.DistinctIpsPerActorThreshold; i++)
|
||||
{
|
||||
await store.AppendAsync(
|
||||
await store.Store.AppendAsync(
|
||||
new AuditRecordDto(
|
||||
AuditEvents.TenantLoginOk,
|
||||
AuditActorTypes.Tenant,
|
||||
@@ -113,10 +114,10 @@ public sealed class SuspiciousActivityServiceTests
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_AuthFailuresPerTenant_Triggers()
|
||||
{
|
||||
var store = new FakeAuditLogStore();
|
||||
var store = new TestAuditLogStore();
|
||||
for (int i = 0; i < SuspiciousActivityService.AuthFailuresPerTenantThreshold; i++)
|
||||
{
|
||||
await store.AppendAsync(
|
||||
await store.Store.AppendAsync(
|
||||
new AuditRecordDto(
|
||||
AuditEvents.TenantLoginFailed,
|
||||
AuditActorTypes.Tenant,
|
||||
@@ -140,7 +141,7 @@ public sealed class SuspiciousActivityServiceTests
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_DistinctLoginsPerIp_TriggersAtThreshold()
|
||||
{
|
||||
var store = new FakeAuditLogStore();
|
||||
var store = new TestAuditLogStore();
|
||||
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++)
|
||||
{
|
||||
SeedFailed(store, ip: "10.3.0.1", login: $"user{i}", minutesAgo: i);
|
||||
@@ -160,7 +161,7 @@ public sealed class SuspiciousActivityServiceTests
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_DistinctLoginsPerIp_DoubleThreshold_IsHigh()
|
||||
{
|
||||
var store = new FakeAuditLogStore();
|
||||
var store = new TestAuditLogStore();
|
||||
int count = SuspiciousActivityService.DistinctLoginsPerIpThreshold * 2;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
@@ -180,7 +181,7 @@ public sealed class SuspiciousActivityServiceTests
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_DistinctLoginsPerIp_BelowThreshold_NoFinding()
|
||||
{
|
||||
var store = new FakeAuditLogStore();
|
||||
var store = new TestAuditLogStore();
|
||||
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold - 1; i++)
|
||||
{
|
||||
SeedFailed(store, ip: "10.3.0.3", login: $"user{i}", minutesAgo: i);
|
||||
@@ -195,7 +196,7 @@ public sealed class SuspiciousActivityServiceTests
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_DistinctLoginsPerIp_SameLoginRepeated_NoFinding()
|
||||
{
|
||||
var store = new FakeAuditLogStore();
|
||||
var store = new TestAuditLogStore();
|
||||
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold + 1; i++)
|
||||
{
|
||||
SeedFailed(store, ip: "10.3.0.4", login: "repeated", minutesAgo: i);
|
||||
@@ -210,7 +211,7 @@ public sealed class SuspiciousActivityServiceTests
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_DistinctLoginsPerIp_SuccessfulLoginsIgnored()
|
||||
{
|
||||
var store = new FakeAuditLogStore();
|
||||
var store = new TestAuditLogStore();
|
||||
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++)
|
||||
{
|
||||
SeedSuccess(store, ip: "10.3.0.5", login: $"user{i}", minutesAgo: i);
|
||||
@@ -225,7 +226,7 @@ public sealed class SuspiciousActivityServiceTests
|
||||
[Fact]
|
||||
public async Task AnalyzeAsync_RecordsOutsideWindow_AreIgnored()
|
||||
{
|
||||
var store = new FakeAuditLogStore();
|
||||
var store = new TestAuditLogStore();
|
||||
for (int i = 0; i < 50; i++)
|
||||
{
|
||||
SeedFailed(store, ip: "10.0.0.1", login: "user", minutesAgo: 60 * 30); // 30 часов назад — вне суток
|
||||
@@ -239,12 +240,12 @@ public sealed class SuspiciousActivityServiceTests
|
||||
|
||||
// Пишет запись «неудачный вход» с заданным временем.
|
||||
private static void SeedFailed(
|
||||
FakeAuditLogStore store,
|
||||
TestAuditLogStore store,
|
||||
string ip,
|
||||
string login,
|
||||
int minutesAgo)
|
||||
{
|
||||
store.AppendAsync(
|
||||
store.Store.AppendAsync(
|
||||
new AuditRecordDto(
|
||||
AuditEvents.TenantLoginFailed,
|
||||
AuditActorTypes.Tenant,
|
||||
@@ -258,12 +259,12 @@ public sealed class SuspiciousActivityServiceTests
|
||||
|
||||
// Пишет запись «успешный вход» с заданным временем.
|
||||
private static void SeedSuccess(
|
||||
FakeAuditLogStore store,
|
||||
TestAuditLogStore store,
|
||||
string ip,
|
||||
string login,
|
||||
int minutesAgo)
|
||||
{
|
||||
store.AppendAsync(
|
||||
store.Store.AppendAsync(
|
||||
new AuditRecordDto(
|
||||
AuditEvents.TenantLoginOk,
|
||||
AuditActorTypes.Tenant,
|
||||
@@ -275,5 +276,5 @@ public sealed class SuspiciousActivityServiceTests
|
||||
CancellationToken.None);
|
||||
}
|
||||
|
||||
private static SuspiciousActivityService Create(FakeAuditLogStore store) => new(store, () => Now);
|
||||
private static SuspiciousActivityService Create(TestAuditLogStore store) => new(store.Store, () => Now);
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ public sealed class TenantAdminServiceTests
|
||||
{
|
||||
var tenantStore = new TestTenantStore();
|
||||
var provisioner = new TestTenantProvisioner();
|
||||
var service = NewService(tenantStore, new FakeAuthStore(), provisioner);
|
||||
var service = NewService(tenantStore, new TestAuthStore(), provisioner);
|
||||
|
||||
TenantCreateResultDto result = await service.CreateAsync(" Новый тенант ", email: null, CancellationToken.None);
|
||||
|
||||
@@ -42,7 +42,7 @@ public sealed class TenantAdminServiceTests
|
||||
public async Task CreateAsync_WithEmail_CreatesOwnerWithOneTimePassword()
|
||||
{
|
||||
var tenantStore = new TestTenantStore();
|
||||
var authStore = new FakeAuthStore();
|
||||
var authStore = new TestAuthStore();
|
||||
var hasher = TestHashers.New();
|
||||
var service = NewService(tenantStore, authStore, new TestTenantProvisioner(), hasher);
|
||||
|
||||
@@ -66,7 +66,7 @@ public sealed class TenantAdminServiceTests
|
||||
public async Task CreateAsync_WithTakenEmail_ReturnsEmailTakenWithoutTenant()
|
||||
{
|
||||
var tenantStore = new TestTenantStore();
|
||||
var authStore = new FakeAuthStore();
|
||||
var authStore = new TestAuthStore();
|
||||
authStore.AddUser(NewUser(Guid.NewGuid(), "owner@example.com"));
|
||||
var service = NewService(tenantStore, authStore);
|
||||
|
||||
@@ -82,7 +82,7 @@ public sealed class TenantAdminServiceTests
|
||||
public async Task CreateAsync_WithInvalidEmail_ReturnsInvalidEmail()
|
||||
{
|
||||
var tenantStore = new TestTenantStore();
|
||||
var service = NewService(tenantStore, new FakeAuthStore());
|
||||
var service = NewService(tenantStore, new TestAuthStore());
|
||||
|
||||
TenantCreateResultDto result = await service.CreateAsync("Тенант", "not-an-email", CancellationToken.None);
|
||||
|
||||
@@ -95,7 +95,7 @@ public sealed class TenantAdminServiceTests
|
||||
public async Task CreateAsync_WithEmptyName_ReturnsNameRequired()
|
||||
{
|
||||
var tenantStore = new TestTenantStore();
|
||||
var service = NewService(tenantStore, new FakeAuthStore());
|
||||
var service = NewService(tenantStore, new TestAuthStore());
|
||||
|
||||
TenantCreateResultDto result = await service.CreateAsync(" ", email: null, CancellationToken.None);
|
||||
|
||||
@@ -110,7 +110,7 @@ public sealed class TenantAdminServiceTests
|
||||
var tenantStore = new TestTenantStore(
|
||||
new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow.AddDays(-2)),
|
||||
new TenantRecordDto(SecondTenantId, "Второй", TenantStatuses.Suspended, DateTimeOffset.UtcNow.AddDays(-1)));
|
||||
var authStore = new FakeAuthStore();
|
||||
var authStore = new TestAuthStore();
|
||||
authStore.AddUser(NewUser(FirstTenantId, "a@example.com"));
|
||||
authStore.AddUser(NewUser(FirstTenantId, "b@example.com"));
|
||||
authStore.AddUser(NewUser(Guid.NewGuid(), "other@example.com"));
|
||||
@@ -132,7 +132,7 @@ public sealed class TenantAdminServiceTests
|
||||
public async Task GetAsync_ForExistingTenant_ReturnsDetailWithUsers()
|
||||
{
|
||||
var tenantStore = new TestTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow));
|
||||
var authStore = new FakeAuthStore();
|
||||
var authStore = new TestAuthStore();
|
||||
authStore.AddUser(NewUser(FirstTenantId, "a@example.com"));
|
||||
authStore.AddUser(NewUser(Guid.NewGuid(), "other@example.com"));
|
||||
var service = NewService(tenantStore, authStore);
|
||||
@@ -150,7 +150,7 @@ public sealed class TenantAdminServiceTests
|
||||
[Fact]
|
||||
public async Task GetAsync_ForUnknownTenant_ReturnsNull()
|
||||
{
|
||||
var service = NewService(new TestTenantStore(), new FakeAuthStore());
|
||||
var service = NewService(new TestTenantStore(), new TestAuthStore());
|
||||
|
||||
Assert.Null(await service.GetAsync(Guid.NewGuid(), CancellationToken.None));
|
||||
}
|
||||
@@ -159,7 +159,7 @@ public sealed class TenantAdminServiceTests
|
||||
public async Task ChangeStatusAsync_Suspend_UpdatesTenantAndReportsChanged()
|
||||
{
|
||||
var tenantStore = new TestTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow));
|
||||
var service = NewService(tenantStore, new FakeAuthStore());
|
||||
var service = NewService(tenantStore, new TestAuthStore());
|
||||
|
||||
TenantStatusChangeResultDto result = await service.ChangeStatusAsync(FirstTenantId, TenantStatuses.Suspended, CancellationToken.None);
|
||||
|
||||
@@ -174,7 +174,7 @@ public sealed class TenantAdminServiceTests
|
||||
public async Task ChangeStatusAsync_WhenAlreadyInStatus_ReturnsOkWithoutChange()
|
||||
{
|
||||
var tenantStore = new TestTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
|
||||
var service = NewService(tenantStore, new FakeAuthStore());
|
||||
var service = NewService(tenantStore, new TestAuthStore());
|
||||
|
||||
// Повторный suspend приостановленного — Ok без изменения (аудит не дублируется, идемпотентность).
|
||||
TenantStatusChangeResultDto result = await service.ChangeStatusAsync(FirstTenantId, TenantStatuses.Suspended, CancellationToken.None);
|
||||
@@ -187,7 +187,7 @@ public sealed class TenantAdminServiceTests
|
||||
[Fact]
|
||||
public async Task ChangeStatusAsync_ForUnknownTenant_ReturnsNotFound()
|
||||
{
|
||||
var service = NewService(new TestTenantStore(), new FakeAuthStore());
|
||||
var service = NewService(new TestTenantStore(), new TestAuthStore());
|
||||
|
||||
TenantStatusChangeResultDto result = await service.ChangeStatusAsync(Guid.NewGuid(), TenantStatuses.Suspended, CancellationToken.None);
|
||||
|
||||
@@ -196,15 +196,15 @@ public sealed class TenantAdminServiceTests
|
||||
Assert.Null(result.Tenant);
|
||||
}
|
||||
|
||||
// Сервис на фейк-хранилищах (владелец-пользователи создаются через FakeAuthStore и хэшер-подставку).
|
||||
// Сервис на подставках хранилищ (владелец-пользователи создаются через TestAuthStore и хэшер-подставку).
|
||||
private static TenantAdminService NewService(
|
||||
TestTenantStore tenantStore,
|
||||
FakeAuthStore authStore,
|
||||
TestAuthStore authStore,
|
||||
TestTenantProvisioner? provisioner = null,
|
||||
IPasswordHasher? hasher = null)
|
||||
{
|
||||
var tenantService = new TenantService(tenantStore.Repository, provisioner?.Provisioner ?? new TestTenantProvisioner().Provisioner);
|
||||
return new TenantAdminService(tenantStore.Repository, authStore, tenantService, hasher ?? TestHashers.New());
|
||||
return new TenantAdminService(tenantStore.Repository, authStore.Store, tenantService, hasher ?? TestHashers.New());
|
||||
}
|
||||
|
||||
// Пользователь тенанта (активный, без хэша — для чтения).
|
||||
|
||||
+38
-23
@@ -1,13 +1,15 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Deal.Tests.Unit.Modules.Tenants;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory реализация <see cref="ITenantLimitStore"/> для юнит-тестов recorder'а и бюджетного гейта/алертов
|
||||
/// Подставка <see cref="ITenantLimitStore"/> для юнит-тестов recorder'а и бюджетного гейта/алертов:
|
||||
/// сервисы получают NSubstitute-подставку (<see cref="Store"/>), тесты управляют сценарием через свойства
|
||||
/// </summary>
|
||||
public sealed class FakeTenantLimitStore : ITenantLimitStore
|
||||
public sealed class TestTenantLimitStore
|
||||
{
|
||||
private sealed class Row
|
||||
{
|
||||
@@ -28,26 +30,46 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
|
||||
|
||||
private readonly Dictionary<Guid, Row> _rows = new();
|
||||
private readonly TokenBudgetService _budgetService = new();
|
||||
|
||||
/// <summary>
|
||||
/// Подставка порта лимитов (создаётся в конструкторе).
|
||||
/// </summary>
|
||||
public ITenantLimitStore Store { get; }
|
||||
private readonly Func<DateTimeOffset> _utcNow;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт фейк с системными часами и статусом тенанта active.
|
||||
/// Создаёт подставку с системными часами и статусом тенанта active.
|
||||
/// </summary>
|
||||
public FakeTenantLimitStore()
|
||||
public TestTenantLimitStore()
|
||||
: this(() => DateTimeOffset.UtcNow, TenantStatuses.Active)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт фейк с заданными часами и статусом тенанта
|
||||
/// Создаёт подставку с заданными часами и статусом тенанта
|
||||
/// </summary>
|
||||
/// <param name="utcNow">Источник текущего времени (UTC).</param>
|
||||
/// <param name="tenantStatus">Статус тенанта для всех строк (константа <see cref="TenantStatuses"/>).</param>
|
||||
public FakeTenantLimitStore(Func<DateTimeOffset> utcNow, string tenantStatus = TenantStatuses.Active)
|
||||
public TestTenantLimitStore(Func<DateTimeOffset> utcNow, string tenantStatus = TenantStatuses.Active)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(utcNow);
|
||||
_utcNow = utcNow;
|
||||
TenantStatus = tenantStatus;
|
||||
Store = Substitute.For<ITenantLimitStore>();
|
||||
Store.GetOrCreateAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>(), Arg.Any<TokenLimitDefaults?>())
|
||||
.Returns(ci => GetOrCreateAsync(ci.ArgAt<Guid>(0), ci.ArgAt<CancellationToken>(1), ci.ArgAt<TokenLimitDefaults?>(2)));
|
||||
Store.GetStateAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => GetStateAsync(ci.ArgAt<Guid>(0), ci.ArgAt<CancellationToken>(1)));
|
||||
Store.AddUsageAsync(Arg.Any<Guid>(), Arg.Any<long>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => AddUsageAsync(ci.ArgAt<Guid>(0), ci.ArgAt<long>(1), ci.ArgAt<CancellationToken>(2)));
|
||||
Store.UpdateBudgetAsync(Arg.Any<Guid>(), Arg.Any<long>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => UpdateBudgetAsync(ci.ArgAt<Guid>(0), ci.ArgAt<long>(1), ci.ArgAt<string>(2), ci.ArgAt<CancellationToken>(3)));
|
||||
Store.TryMarkWarnedAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => TryMarkWarnedAsync(ci.ArgAt<Guid>(0), ci.ArgAt<CancellationToken>(1)));
|
||||
Store.TryMarkNotifiedExhaustedAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => TryMarkNotifiedExhaustedAsync(ci.ArgAt<Guid>(0), ci.ArgAt<CancellationToken>(1)));
|
||||
Store.ResetExpiredPeriodsAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => ResetExpiredPeriodsAsync(ci.ArgAt<DateTimeOffset>(0), ci.ArgAt<CancellationToken>(1)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -56,12 +78,12 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
|
||||
public string TenantStatus { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Тенанты, для которых <see cref="GetStateAsync"/> бросает исключение (устойчивость сборщиков)
|
||||
/// Тенанты, для которых <see cref="GetStateAsync"/> подставки бросает исключение (устойчивость сборщиков)
|
||||
/// </summary>
|
||||
public HashSet<Guid> FailStateReads { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Кладёт готовую строку лимита
|
||||
/// Кладёт готовую строку лимита (в состояние подставки)
|
||||
/// </summary>
|
||||
/// <param name="tenantId">Тенант.</param>
|
||||
/// <param name="budgetTokens">Бюджет периода.</param>
|
||||
@@ -116,18 +138,16 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
|
||||
/// <returns>True — строка существует.</returns>
|
||||
public bool Exists(Guid tenantId) => _rows.ContainsKey(tenantId);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<TenantLimitDto> GetOrCreateAsync(
|
||||
private Task<TenantLimitDto> GetOrCreateAsync(
|
||||
Guid tenantId,
|
||||
CancellationToken ct,
|
||||
TokenLimitDefaults? defaults = null)
|
||||
TokenLimitDefaults? defaults)
|
||||
{
|
||||
Row row = Ensure(tenantId, defaults ?? TokenBudgetDefaults.Default);
|
||||
return Task.FromResult(ToLimitDto(row, tenantId));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<BudgetStateDto> GetStateAsync(Guid tenantId, CancellationToken ct)
|
||||
private Task<BudgetStateDto> GetStateAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
if (FailStateReads.Contains(tenantId))
|
||||
{
|
||||
@@ -139,8 +159,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
|
||||
return Task.FromResult(ToStateDto(row, tenantId));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<BudgetStateDto> AddUsageAsync(
|
||||
private Task<BudgetStateDto> AddUsageAsync(
|
||||
Guid tenantId,
|
||||
long tokens,
|
||||
CancellationToken ct)
|
||||
@@ -155,8 +174,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
|
||||
return Task.FromResult(ToStateDto(row, tenantId));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<BudgetStateDto> UpdateBudgetAsync(
|
||||
private Task<BudgetStateDto> UpdateBudgetAsync(
|
||||
Guid tenantId,
|
||||
long budgetTokens,
|
||||
string period,
|
||||
@@ -170,8 +188,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
|
||||
return Task.FromResult(ToStateDto(row, tenantId));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> TryMarkWarnedAsync(Guid tenantId, CancellationToken ct)
|
||||
private Task<bool> TryMarkWarnedAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
Row row = Ensure(tenantId, TokenBudgetDefaults.Default);
|
||||
ResetIfPeriodExpired(row);
|
||||
@@ -184,8 +201,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct)
|
||||
private Task<bool> TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct)
|
||||
{
|
||||
Row row = Ensure(tenantId, TokenBudgetDefaults.Default);
|
||||
ResetIfPeriodExpired(row);
|
||||
@@ -198,8 +214,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
|
||||
return Task.FromResult(true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct)
|
||||
private Task<int> ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct)
|
||||
{
|
||||
int reset = 0;
|
||||
foreach (Row row in _rows.Values)
|
||||
@@ -3,6 +3,7 @@ using Deal.Api.Dtos;
|
||||
using Deal.Api.Events;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
@@ -10,8 +11,8 @@ using Deal.Modules.Pipeline.Application.Parse;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Tests.Unit.Contracts;
|
||||
using Deal.Tests.Unit.Modules.Cards;
|
||||
using Deal.Tests.Unit.Modules.Kanban;
|
||||
using NSubstitute;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
|
||||
@@ -28,9 +29,9 @@ public sealed class AdminTickOrchestratorTests
|
||||
// Контекст теста: оркестратор на общих фейках + канал подписки.
|
||||
private sealed record Context(
|
||||
AdminTickOrchestrator Orchestrator,
|
||||
FakePipelineStore PipelineStore,
|
||||
FakeKanjStore KanjStore,
|
||||
FakeSettingsStore Settings,
|
||||
TestPipelineStore PipelineStore,
|
||||
TestKanjStore KanjStore,
|
||||
TestSettingsStore Settings,
|
||||
TestAiClassifier AiClassifier,
|
||||
PipelinePumpGate PumpGate,
|
||||
SseSubscription Subscription);
|
||||
@@ -158,7 +159,7 @@ public sealed class AdminTickOrchestratorTests
|
||||
Assert.Equal("hold", payload.RootElement.GetProperty("containerId").GetString());
|
||||
|
||||
// «Выстрелившее» помечено fired: повторная выборка due пуста (признак держит строка БД).
|
||||
Assert.Empty(await ctx.KanjStore.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None));
|
||||
Assert.Empty(await ctx.KanjStore.Store.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -194,33 +195,39 @@ public sealed class AdminTickOrchestratorTests
|
||||
|
||||
// Собирает оркестратор на общих фейках: StorageTickService и воркер делят KanjStore/Settings,
|
||||
// обработка и воркер — PipelineStore, напоминания — тот же KanjStore/Settings (CardsService);
|
||||
// ML «не готов» (FakeMlClient.Predict не задан → сбой → «не уверен»).
|
||||
// ML «не готов» (TestMlClient.Predict не задан → сбой → «не уверен»).
|
||||
// withThrowingQueueRead: true — чтение очереди воркером бросает (сценарий сбоя pump).
|
||||
// withThrowingReminderCheck: true — выборка due-напоминаний бросает (сценарий сбоя проверки).
|
||||
// Возвращает: Оркестратор, фейки и подписку канала тенанта.
|
||||
private static Context CreateContext(bool withThrowingQueueRead = false, bool withThrowingReminderCheck = false)
|
||||
{
|
||||
var settings = new FakeSettingsStore();
|
||||
var settings = new TestSettingsStore();
|
||||
// При сбое чтения очереди подменяем хранилище целиком (тот же объект идёт во все сервисы и в контекст).
|
||||
FakePipelineStore store = withThrowingQueueRead ? new ThrowingQueueReadPipelineStore() : new FakePipelineStore();
|
||||
var kanjStore = new FakeKanjStore();
|
||||
var mlClient = new FakeMlClient();
|
||||
TestPipelineStore store = new();
|
||||
if (withThrowingQueueRead)
|
||||
{
|
||||
store.Store.ListAsync(Arg.Any<string?>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns<IReadOnlyList<QueueItemDto>>(_ =>
|
||||
throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync)."));
|
||||
}
|
||||
var kanjStore = new TestKanjStore();
|
||||
var mlClient = new TestMlClient();
|
||||
var aiClassifier = new TestAiClassifier();
|
||||
var rules = new IncomingRules(settings);
|
||||
var fieldsParser = new LocalFieldsParser(settings);
|
||||
var ingest = new PipelineIngestService(store);
|
||||
var processing = new PipelineProcessingService(store, mlClient, ingest);
|
||||
var composer = new CardComposer(kanjStore, settings);
|
||||
var writer = new PipelineCardWriter(kanjStore, store, composer);
|
||||
var rules = new IncomingRules(settings.Store);
|
||||
var fieldsParser = new LocalFieldsParser(settings.Store);
|
||||
var ingest = new PipelineIngestService(store.Store);
|
||||
var processing = new PipelineProcessingService(store.Store, mlClient.Client, ingest);
|
||||
var composer = new CardComposer(kanjStore.Store, settings.Store);
|
||||
var writer = new PipelineCardWriter(kanjStore.Store, store.Store, composer);
|
||||
var worker = new PipelineWorkerService(
|
||||
store, settings, rules, kanjStore, mlClient, aiClassifier.Classifier, processing, writer, fieldsParser);
|
||||
store.Store, settings.Store, rules, kanjStore.Store, mlClient.Client, aiClassifier.Classifier, processing, writer, fieldsParser);
|
||||
|
||||
var broker = new SseBroker();
|
||||
var tickService = new StorageTickService(kanjStore, settings);
|
||||
var tickService = new StorageTickService(kanjStore.Store, settings.Store);
|
||||
var toastPublisher = new StorageToastPublisher(broker);
|
||||
var pumpGate = new PipelinePumpGate();
|
||||
FakeKanjStore reminderStore = withThrowingReminderCheck ? new ThrowingDueKanjStore() : kanjStore;
|
||||
var cardsService = new CardsService(reminderStore, settings, mlClient, new TestFileStorage().Storage);
|
||||
TestKanjStore reminderStore = withThrowingReminderCheck ? new TestKanjStore(throwOnDueReminders: true) : kanjStore;
|
||||
var cardsService = new CardsService(reminderStore.Store, settings.Store, mlClient.Client, new TestFileStorage().Storage);
|
||||
var orchestrator = new AdminTickOrchestrator(
|
||||
tickService, processing, worker, cardsService, toastPublisher, broker, pumpGate,
|
||||
NullLogger<AdminTickOrchestrator>.Instance);
|
||||
@@ -229,16 +236,7 @@ public sealed class AdminTickOrchestratorTests
|
||||
broker.Subscribe(TenantA));
|
||||
}
|
||||
|
||||
// Хранилище карточек со сбоем выборки due-напоминаний: ListDueRemindersAsync бросает (сценарий
|
||||
// «БД/схема недоступны» на проверке напоминаний — тик продолжается, reminders ответа пуст).
|
||||
private sealed class ThrowingDueKanjStore : FakeKanjStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override Task<IReadOnlyList<CardReminderDueDto>> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct)
|
||||
{
|
||||
throw new InvalidOperationException("Тестовый сбой выборки due-напоминаний (ListDueRemindersAsync).");
|
||||
}
|
||||
}
|
||||
// Сбой выборки due-напоминаний задаётся флагом throwOnDueReminders в TestKanjStore.
|
||||
|
||||
// Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем.
|
||||
private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
@@ -261,19 +259,6 @@ public sealed class AdminTickOrchestratorTests
|
||||
};
|
||||
}
|
||||
|
||||
// Хранилище со сбоем чтения очереди: ListAsync бросает (сценарий «БД/схема недоступны» на pump).
|
||||
private sealed class ThrowingQueueReadPipelineStore : FakePipelineStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override Task<IReadOnlyList<QueueItemDto>> ListAsync(
|
||||
string? status,
|
||||
int limit,
|
||||
CancellationToken ct)
|
||||
{
|
||||
throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync).");
|
||||
}
|
||||
}
|
||||
|
||||
// Строка очереди (status new; сценарий перекрывает текст/время).
|
||||
private static QueueItemDto QueueRow(string id, string text) => SourceItemFactory.Queue(
|
||||
id,
|
||||
|
||||
@@ -3,6 +3,7 @@ using Deal.Api.Events;
|
||||
using Deal.Api.Hosting;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using Deal.Tests.Unit.Modules.Tenants;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -27,7 +28,7 @@ public sealed class BudgetAlertSchedulerTests
|
||||
[Fact]
|
||||
public async Task RunCycle_TwoCycles_PublishesToastOncePerThresholdPerTenant()
|
||||
{
|
||||
FakeTenantLimitStore limits = new();
|
||||
TestTenantLimitStore limits = new();
|
||||
limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 800);
|
||||
limits.Preload(TenantB, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 1000);
|
||||
await using ServiceProvider provider = BuildProvider(
|
||||
@@ -54,7 +55,7 @@ public sealed class BudgetAlertSchedulerTests
|
||||
[Fact]
|
||||
public async Task RunCycle_TenantBelowThreshold_NoToastAndNoFlagSet()
|
||||
{
|
||||
FakeTenantLimitStore limits = new();
|
||||
TestTenantLimitStore limits = new();
|
||||
limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 700);
|
||||
await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits);
|
||||
|
||||
@@ -66,7 +67,7 @@ public sealed class BudgetAlertSchedulerTests
|
||||
|
||||
Assert.Empty(ReadToasts(subscriptionA));
|
||||
// Порог не достигнут — TryMark* не выставил флаг (повторный проход после роста расхода даст тост).
|
||||
Assert.False(await limits.TryMarkWarnedAsync(TenantA, CancellationToken.None));
|
||||
Assert.False(await limits.Store.TryMarkWarnedAsync(TenantA, CancellationToken.None));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -74,7 +75,7 @@ public sealed class BudgetAlertSchedulerTests
|
||||
{
|
||||
// Строка «уже исчерпан, но ни один флаг не стоял» (сценарий: оператор уменьшил бюджет — флаги сброшены,
|
||||
// расход ≥ бюджета): за один проход выходят оба порога ровно по одному разу.
|
||||
FakeTenantLimitStore limits = new();
|
||||
TestTenantLimitStore limits = new();
|
||||
limits.Preload(TenantA, budgetTokens: 500, TenantLimitPeriods.Month, Now(), usedTokens: 700);
|
||||
await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits);
|
||||
|
||||
@@ -93,7 +94,7 @@ public sealed class BudgetAlertSchedulerTests
|
||||
[Fact]
|
||||
public async Task RunCycle_NaturalSpendCrossing80Then100_PublishesOneToastPerThreshold()
|
||||
{
|
||||
FakeTenantLimitStore limits = new();
|
||||
TestTenantLimitStore limits = new();
|
||||
limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 0);
|
||||
await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits);
|
||||
|
||||
@@ -101,7 +102,7 @@ public sealed class BudgetAlertSchedulerTests
|
||||
SseSubscription subscriptionA = broker.Subscribe(TenantA);
|
||||
BudgetAlertScheduler scheduler = CreateScheduler(provider);
|
||||
|
||||
await limits.AddUsageAsync(TenantA, tokens: 850, CancellationToken.None);
|
||||
await limits.Store.AddUsageAsync(TenantA, tokens: 850, CancellationToken.None);
|
||||
|
||||
await scheduler.RunCycleAsync(CancellationToken.None);
|
||||
Assert.Equal(new[] { (Warned80ToastText, "bell") }, ReadToasts(subscriptionA));
|
||||
@@ -110,7 +111,7 @@ public sealed class BudgetAlertSchedulerTests
|
||||
Assert.Empty(ReadToasts(subscriptionA)); // повторный проход — флаг уже стоит, тост не дублируется
|
||||
|
||||
// Расход до исчерпания (850 + 200 = 1050 ≥ 1000) → ещё ровно один тост (100%); 80% уже отмечен.
|
||||
await limits.AddUsageAsync(TenantA, tokens: 200, CancellationToken.None);
|
||||
await limits.Store.AddUsageAsync(TenantA, tokens: 200, CancellationToken.None);
|
||||
await scheduler.RunCycleAsync(CancellationToken.None);
|
||||
Assert.Equal(new[] { (ExhaustedToastText, "bell") }, ReadToasts(subscriptionA));
|
||||
|
||||
@@ -124,11 +125,11 @@ public sealed class BudgetAlertSchedulerTests
|
||||
// tenants: Фейк реестра тенантов (обход прохода).
|
||||
// limits: Фейк-хранилище лимитов (строки посеяны сценарием до прохода).
|
||||
// Возвращает: Провайдер с сервисами цикла.
|
||||
private static ServiceProvider BuildProvider(ITenantRepository tenants, FakeTenantLimitStore limits)
|
||||
private static ServiceProvider BuildProvider(ITenantRepository tenants, TestTenantLimitStore limits)
|
||||
{
|
||||
var services = new ServiceCollection();
|
||||
services.AddSingleton(tenants);
|
||||
services.AddScoped<ITenantLimitStore>(_ => limits);
|
||||
services.AddScoped<ITenantLimitStore>(_ => limits.Store);
|
||||
services.AddSingleton<SseBroker>();
|
||||
return services.BuildServiceProvider();
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using Deal.Modules.Cards.Application.Sources;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
@@ -8,7 +9,6 @@ using Deal.Modules.Pipeline.Application.Parse;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Tests.Unit.Contracts;
|
||||
using Deal.Tests.Unit.Modules.Cards;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
|
||||
namespace Deal.Tests.Unit.Support;
|
||||
@@ -21,9 +21,9 @@ public sealed class CardReclassifierTests
|
||||
// Контекст теста: сервис поверх in-memory фейков.
|
||||
private sealed record Context(
|
||||
CardReclassifier Reclassifier,
|
||||
FakeKanjStore Store,
|
||||
FakeSettingsStore Settings,
|
||||
FakeMlClient MlClient,
|
||||
TestKanjStore Store,
|
||||
TestSettingsStore Settings,
|
||||
TestMlClient MlClient,
|
||||
TestAiClassifier AiClassifier,
|
||||
ReclassifyGate Gate);
|
||||
|
||||
@@ -31,16 +31,16 @@ public sealed class CardReclassifierTests
|
||||
// Возвращает: Сервис и фейки (сценарий до-настраивает карточки/доски/настройки).
|
||||
private static Context CreateContext()
|
||||
{
|
||||
var settings = new FakeSettingsStore();
|
||||
var store = new FakeKanjStore();
|
||||
var mlClient = new FakeMlClient();
|
||||
var settings = new TestSettingsStore();
|
||||
var store = new TestKanjStore();
|
||||
var mlClient = new TestMlClient();
|
||||
var aiClassifier = new TestAiClassifier();
|
||||
var fieldsParser = new LocalFieldsParser(settings);
|
||||
var composer = new CardComposer(store, settings);
|
||||
var cardsService = new CardsService(store, settings, mlClient, new TestFileStorage().Storage);
|
||||
var fieldsParser = new LocalFieldsParser(settings.Store);
|
||||
var composer = new CardComposer(store.Store, settings.Store);
|
||||
var cardsService = new CardsService(store.Store, settings.Store, mlClient.Client, new TestFileStorage().Storage);
|
||||
var gate = new ReclassifyGate();
|
||||
var reclassifier = new CardReclassifier(
|
||||
store, settings, aiClassifier.Classifier, fieldsParser, composer, cardsService, mlClient, gate);
|
||||
store.Store, settings.Store, aiClassifier.Classifier, fieldsParser, composer, cardsService, mlClient.Client, gate);
|
||||
return new Context(reclassifier, store, settings, mlClient, aiClassifier, gate);
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@ using System.Text;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Tests.Unit.Contracts;
|
||||
using Deal.Tests.Unit.Modules.Cards;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
|
||||
namespace Deal.Tests.Unit.Support;
|
||||
@@ -16,7 +15,7 @@ public sealed class CardsServiceFilesTests
|
||||
[Fact]
|
||||
public async Task Add_ImageMime_DetectsKindByMimeWritesObjectAndMeta()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
|
||||
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
|
||||
store.SeedCard(Card("c_1", updatedAtMs: 1));
|
||||
byte[] content = Encoding.UTF8.GetBytes("данные-картинки");
|
||||
using MemoryStream stream = new(content);
|
||||
@@ -42,7 +41,7 @@ public sealed class CardsServiceFilesTests
|
||||
[Fact]
|
||||
public async Task Add_PdfExtensionWithoutMime_DetectsKindByExtension()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _) = Create();
|
||||
store.SeedCard(Card("c_1"));
|
||||
|
||||
CardFileDto? entry = await service.AddFileAsync(
|
||||
@@ -57,7 +56,7 @@ public sealed class CardsServiceFilesTests
|
||||
[Fact]
|
||||
public async Task Add_TwoFiles_AppendsPreservingOrderAndBothObjects()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
|
||||
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
|
||||
store.SeedCard(Card("c_1"));
|
||||
|
||||
CardFileDto? first = await service.AddFileAsync(
|
||||
@@ -76,7 +75,7 @@ public sealed class CardsServiceFilesTests
|
||||
[Fact]
|
||||
public async Task Add_CardMissing_ReturnsNullAndDoesNotWriteObject()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
|
||||
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
|
||||
|
||||
CardFileDto? entry = await service.AddFileAsync(
|
||||
"c_missing", "photo.png", "image/png", new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None);
|
||||
@@ -89,7 +88,7 @@ public sealed class CardsServiceFilesTests
|
||||
[Fact]
|
||||
public async Task Add_EmptyOrWhitespaceFileName_DefaultsToPrototypeFile()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _) = Create();
|
||||
store.SeedCard(Card("c_1"));
|
||||
|
||||
CardFileDto? entry = await service.AddFileAsync(
|
||||
@@ -105,7 +104,7 @@ public sealed class CardsServiceFilesTests
|
||||
[Fact]
|
||||
public async Task Add_FileNameWithPathAndQuoteChars_SanitizesObjectKeyButKeepsMetaName()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
|
||||
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
|
||||
store.SeedCard(Card("c_1"));
|
||||
string rawName = "..\\файл\"отчёта v2.pdf";
|
||||
|
||||
@@ -123,7 +122,7 @@ public sealed class CardsServiceFilesTests
|
||||
[Fact]
|
||||
public async Task Add_StreamPositionNotZero_StoresWholeContent()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
|
||||
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
|
||||
store.SeedCard(Card("c_1"));
|
||||
byte[] content = Encoding.UTF8.GetBytes("полное-содержимое-файла");
|
||||
using MemoryStream stream = new(content) { Position = 5 }; // эндпоинт мог прочитать поток раньше
|
||||
@@ -141,7 +140,7 @@ public sealed class CardsServiceFilesTests
|
||||
[Fact]
|
||||
public async Task GetEntry_ExistingFile_ReturnsEntryMeta()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _) = Create();
|
||||
var file = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000000_tz.pdf");
|
||||
store.SeedCard(Card("c_1") with { Files = new[] { file } });
|
||||
|
||||
@@ -164,7 +163,7 @@ public sealed class CardsServiceFilesTests
|
||||
[Fact]
|
||||
public async Task GetEntry_FileNotInMetadata_ReturnsNull()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _) = Create();
|
||||
store.SeedCard(Card("c_1")
|
||||
with { Files = new[] { new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "k") } });
|
||||
|
||||
@@ -177,7 +176,7 @@ public sealed class CardsServiceFilesTests
|
||||
[Fact]
|
||||
public async Task Remove_Existing_DeletesObjectRemovesMetaAndReturnsCard()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
|
||||
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
|
||||
var first = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000001_tz.pdf");
|
||||
var second = new CardFileDto("pf_2", "photo.png", 200, "image", "Изображение", "projects/c_1/1710000000002_photo.png");
|
||||
store.SeedCard(Card("c_1", updatedAtMs: 1) with { Files = new[] { first, second } });
|
||||
@@ -199,7 +198,7 @@ public sealed class CardsServiceFilesTests
|
||||
[Fact]
|
||||
public async Task Remove_UnknownFileId_ReturnsCardUnchangedWithoutStorageDelete()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
|
||||
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
|
||||
var file = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000001_tz.pdf");
|
||||
store.SeedCard(Card("c_1") with { Files = new[] { file } });
|
||||
|
||||
@@ -214,7 +213,7 @@ public sealed class CardsServiceFilesTests
|
||||
[Fact]
|
||||
public async Task Remove_EntryWithEmptyObjectKey_SkipsStorageDelete()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
|
||||
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
|
||||
store.SeedCard(Card("c_1")
|
||||
with { Files = new[] { new CardFileDto("pf_mock", "meta-only.pdf", 10, "document", "Документ", ObjectKey: string.Empty) } });
|
||||
|
||||
@@ -238,11 +237,11 @@ public sealed class CardsServiceFilesTests
|
||||
|
||||
// ─── Хелперы ──────────────────────────────────────────────────────────
|
||||
|
||||
private static (CardsService Service, FakeKanjStore Store, TestFileStorage Storage) Create()
|
||||
private static (CardsService Service, TestKanjStore Store, TestFileStorage Storage) Create()
|
||||
{
|
||||
var store = new FakeKanjStore();
|
||||
var store = new TestKanjStore();
|
||||
var storage = new TestFileStorage();
|
||||
return (new CardsService(store, new FakeSettingsStore(), new FakeMlClient(), storage.Storage), store, storage);
|
||||
return (new CardsService(store.Store, new TestSettingsStore().Store, new TestMlClient().Client, storage.Storage), store, storage);
|
||||
}
|
||||
|
||||
// Карточка с полями по умолчанию (planned, CreatedAtMs=1, UpdatedAtMs=1; Files пуст).
|
||||
|
||||
@@ -2,7 +2,6 @@ using System.Text.Json;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Tests.Unit.Contracts;
|
||||
using Deal.Tests.Unit.Modules.Cards;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
|
||||
namespace Deal.Tests.Unit.Support;
|
||||
@@ -16,7 +15,7 @@ public sealed class CardsServiceSelectedTests
|
||||
[Fact]
|
||||
public async Task List_NoStage_ReturnsCardsOrderedByUpdatedAtDesc()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("c_1", updatedAtMs: 100));
|
||||
store.SeedCard(Card("c_2", updatedAtMs: 300));
|
||||
store.SeedCard(Card("c_3", stage: "work", updatedAtMs: 200));
|
||||
@@ -29,7 +28,7 @@ public sealed class CardsServiceSelectedTests
|
||||
[Fact]
|
||||
public async Task List_ByStage_ReturnsOnlyStageCards()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("c_1"));
|
||||
store.SeedCard(Card("c_2", stage: "work"));
|
||||
|
||||
@@ -53,7 +52,7 @@ public sealed class CardsServiceSelectedTests
|
||||
[Fact]
|
||||
public async Task CreateLocal_WithStage_TrimsTitleAndWritesCreatedLocalHistory()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
|
||||
CardDto card = await service.CreateLocalCardAsync(
|
||||
new CardLocalCreateDto(Title: " Задача на бота ", ContainerId: "work"),
|
||||
@@ -102,7 +101,7 @@ public sealed class CardsServiceSelectedTests
|
||||
[Fact]
|
||||
public async Task TakeCard_CardMissing_ReturnsNullAndCreatesNothing()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
|
||||
CardDto? card = await service.TakeCardAsync("c_missing", CancellationToken.None);
|
||||
|
||||
@@ -113,7 +112,7 @@ public sealed class CardsServiceSelectedTests
|
||||
[Fact]
|
||||
public async Task TakeCard_MovesCardToPlannedKeepingFields()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card(
|
||||
"c_1",
|
||||
stage: "inbox",
|
||||
@@ -150,7 +149,7 @@ public sealed class CardsServiceSelectedTests
|
||||
[Fact]
|
||||
public async Task TakeCard_AlreadyInStage_ReturnsCardUnchanged()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("c_1", stage: "work", title: "Уже взята"));
|
||||
|
||||
CardDto? card = await service.TakeCardAsync("c_1", CancellationToken.None);
|
||||
@@ -167,7 +166,7 @@ public sealed class CardsServiceSelectedTests
|
||||
[Fact]
|
||||
public async Task Patch_PresentKeys_UpdateFieldsAndBumpUpdatedAt()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card(
|
||||
"c_1",
|
||||
title: "Старый заголовок",
|
||||
@@ -198,7 +197,7 @@ public sealed class CardsServiceSelectedTests
|
||||
[Fact]
|
||||
public async Task Patch_BudgetNull_ClearsBudget()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("c_1", title: "С бюджетом", updatedAtMs: 1)
|
||||
with { Budget = new CardBudgetDto(1000, 2000, "USD") });
|
||||
|
||||
@@ -213,7 +212,7 @@ public sealed class CardsServiceSelectedTests
|
||||
[Fact]
|
||||
public async Task Patch_StackNull_ClearsStack()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("c_1", title: "С стеком", updatedAtMs: 1)
|
||||
with { Stack = new[] { "Python" } });
|
||||
|
||||
@@ -226,7 +225,7 @@ public sealed class CardsServiceSelectedTests
|
||||
[Fact]
|
||||
public async Task Patch_UnknownKeys_AreIgnored()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("c_1", title: "Старый"));
|
||||
|
||||
CardDto? card = await service.PatchCardAsync(
|
||||
@@ -242,7 +241,7 @@ public sealed class CardsServiceSelectedTests
|
||||
[Fact]
|
||||
public async Task Patch_NullTextKey_IsIgnored()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("c_1", title: "Старый", summary: "Описание", updatedAtMs: 1));
|
||||
|
||||
CardDto? card = await service.PatchCardAsync(
|
||||
@@ -272,7 +271,7 @@ public sealed class CardsServiceSelectedTests
|
||||
[Fact]
|
||||
public async Task Move_TwoMoves_AppendHistoryResetReminderAndBumpUpdatedAt()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
long seededAt = 5_000;
|
||||
CardDto before = Card("c_1", stage: "hold", title: "Отложенная", updatedAtMs: seededAt)
|
||||
with
|
||||
@@ -310,7 +309,7 @@ public sealed class CardsServiceSelectedTests
|
||||
[Fact]
|
||||
public async Task Move_UnknownStage_Returns400ErrorAndLeavesCardUntouched()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("c_1", stage: "planned"));
|
||||
|
||||
CardResultDto result = await service.MoveStageCardAsync("c_1", "stuck", CancellationToken.None);
|
||||
@@ -337,7 +336,7 @@ public sealed class CardsServiceSelectedTests
|
||||
[Fact]
|
||||
public async Task ClearRejected_RemovesOnlyRejectedAndReturnsCount()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("c_1", stage: "rejected"));
|
||||
store.SeedCard(Card("c_2", stage: "rejected"));
|
||||
store.SeedCard(Card("c_3", stage: "finished"));
|
||||
@@ -363,7 +362,7 @@ public sealed class CardsServiceSelectedTests
|
||||
[Fact]
|
||||
public async Task AddComment_Valid_AppendsTrimmedCommentWithWireForm()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1));
|
||||
|
||||
AddCommentResultDto result = await service.AddCommentAsync("c_1", " Перезвонить завтра ", CancellationToken.None);
|
||||
@@ -381,7 +380,7 @@ public sealed class CardsServiceSelectedTests
|
||||
[Fact]
|
||||
public async Task AddComment_TwoComments_AppendsPreservingOrder()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("c_1", title: "Карточка")
|
||||
with { Comments = new[] { new CardCommentDto("cm_1", "Вы", "Первый", "5 мин") } });
|
||||
|
||||
@@ -396,7 +395,7 @@ public sealed class CardsServiceSelectedTests
|
||||
[Fact]
|
||||
public async Task AddComment_EmptyOrWhitespaceText_Returns400DetailAndWritesNothing()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("c_1", title: "Карточка"));
|
||||
|
||||
AddCommentResultDto empty = await service.AddCommentAsync("c_1", string.Empty, CancellationToken.None);
|
||||
@@ -411,7 +410,7 @@ public sealed class CardsServiceSelectedTests
|
||||
[Fact]
|
||||
public async Task AddComment_CardMissing_ReturnsNullComments()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
|
||||
AddCommentResultDto result = await service.AddCommentAsync("c_missing", "Текст", CancellationToken.None);
|
||||
|
||||
@@ -424,7 +423,7 @@ public sealed class CardsServiceSelectedTests
|
||||
[Fact]
|
||||
public async Task AddLink_NoScheme_PrefixesHttpsAndDefaultsNameToUrl()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1)
|
||||
with { Links = new[] { new CardLinkDto("pl_1", "Сайт", "https://example.com") } });
|
||||
|
||||
@@ -444,7 +443,7 @@ public sealed class CardsServiceSelectedTests
|
||||
[Fact]
|
||||
public async Task AddLink_HasHttpScheme_KeepsSchemeAndUsesTrimmedName()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("c_1", title: "Карточка"));
|
||||
|
||||
CardResultDto result = await service.AddLinkAsync("c_1", " Сайт ", "http://site.ru/abc", CancellationToken.None);
|
||||
@@ -458,7 +457,7 @@ public sealed class CardsServiceSelectedTests
|
||||
[Fact]
|
||||
public async Task AddLink_EmptyUrl_Returns400DetailAndWritesNothing()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("c_1", title: "Карточка"));
|
||||
|
||||
CardResultDto result = await service.AddLinkAsync("c_1", "Имя", " ", CancellationToken.None);
|
||||
@@ -471,7 +470,7 @@ public sealed class CardsServiceSelectedTests
|
||||
[Fact]
|
||||
public async Task AddLink_CardMissing_ReturnsNullCardBeforeUrlValidation()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
|
||||
CardResultDto result = await service.AddLinkAsync("c_missing", string.Empty, "example.com", CancellationToken.None);
|
||||
|
||||
@@ -483,7 +482,7 @@ public sealed class CardsServiceSelectedTests
|
||||
[Fact]
|
||||
public async Task RemoveLink_ById_RemovesOnlyTargetAndReturnsCard()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1)
|
||||
with
|
||||
{
|
||||
@@ -507,7 +506,7 @@ public sealed class CardsServiceSelectedTests
|
||||
[Fact]
|
||||
public async Task RemoveLink_UnknownLinkId_LeavesLinksUnchangedWithoutError()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
store.SeedCard(Card("c_1", title: "Карточка")
|
||||
with { Links = new[] { new CardLinkDto("pl_1", "Сайт", "https://a.b") } });
|
||||
|
||||
@@ -520,7 +519,7 @@ public sealed class CardsServiceSelectedTests
|
||||
[Fact]
|
||||
public async Task RemoveLink_CardMissing_ReturnsNullCard()
|
||||
{
|
||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
||||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||
|
||||
CardResultDto result = await service.RemoveLinkAsync("c_missing", "pl_1", CancellationToken.None);
|
||||
|
||||
@@ -531,12 +530,12 @@ public sealed class CardsServiceSelectedTests
|
||||
|
||||
// ─── Хелперы ──────────────────────────────────────────────────────────
|
||||
|
||||
private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings, FakeMlClient Ml) Create()
|
||||
private static (CardsService Service, TestKanjStore Store, TestSettingsStore Settings, TestMlClient Ml) Create()
|
||||
{
|
||||
var store = new FakeKanjStore();
|
||||
var settings = new FakeSettingsStore();
|
||||
var ml = new FakeMlClient();
|
||||
return (new CardsService(store, settings, ml, new TestFileStorage().Storage), store, settings, ml);
|
||||
var store = new TestKanjStore();
|
||||
var settings = new TestSettingsStore();
|
||||
var ml = new TestMlClient();
|
||||
return (new CardsService(store.Store, settings.Store, ml.Client, new TestFileStorage().Storage), store, settings, ml);
|
||||
}
|
||||
|
||||
// Тело PATCH из пар «ключ → значение» (presence = наличие пары; null — явный JSON-null).
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Tests.Unit.Modules.Cards;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
|
||||
namespace Deal.Tests.Unit.Support;
|
||||
@@ -29,7 +28,7 @@ public sealed class ContainersServiceTests
|
||||
[Fact]
|
||||
public async Task Create_ExistingContainers_OrderIsMaxPlusOne()
|
||||
{
|
||||
(ContainersService service, FakeKanjStore store, _) = Create();
|
||||
(ContainersService service, TestKanjStore store, _) = Create();
|
||||
store.SeedBoard(Container("b_2", 2));
|
||||
store.SeedBoard(Container("b_5", 5));
|
||||
|
||||
@@ -43,7 +42,7 @@ public sealed class ContainersServiceTests
|
||||
[Fact]
|
||||
public async Task Create_OrderEight_CyclesPaletteToFirst()
|
||||
{
|
||||
(ContainersService service, FakeKanjStore store, _) = Create();
|
||||
(ContainersService service, TestKanjStore store, _) = Create();
|
||||
store.SeedBoard(Container("b_7", 7));
|
||||
|
||||
ContainerDto container = await service.CreateAsync(new ContainerCreateDto("Middle"), CancellationToken.None);
|
||||
@@ -157,7 +156,7 @@ public sealed class ContainersServiceTests
|
||||
[Fact]
|
||||
public async Task List_FillsCountsFromCards()
|
||||
{
|
||||
(ContainersService service, FakeKanjStore store, _) = Create();
|
||||
(ContainersService service, TestKanjStore store, _) = Create();
|
||||
ContainerDto container = await service.CreateAsync(new ContainerCreateDto("Middle"), CancellationToken.None);
|
||||
store.SeedCard(new CardDto { Id = "c_1", Col = container.Id, IsNew = true });
|
||||
store.SeedCard(new CardDto { Id = "c_2", Col = container.Id, IsNew = true });
|
||||
@@ -283,16 +282,16 @@ public sealed class ContainersServiceTests
|
||||
[Fact]
|
||||
public async Task Reorder_AssignsOrdersInGivenOrder()
|
||||
{
|
||||
(ContainersService service, FakeKanjStore store, _) = Create();
|
||||
(ContainersService service, TestKanjStore store, _) = Create();
|
||||
store.SeedBoard(Container("b_1", 0));
|
||||
store.SeedBoard(Container("b_2", 1));
|
||||
store.SeedBoard(Container("b_3", 2));
|
||||
|
||||
await service.ReorderAsync(ContainerSpaces.Dashboard, ["b_3", "b_1", "b_2"], CancellationToken.None);
|
||||
|
||||
Assert.Equal(0, (await store.GetContainerAsync("b_3", CancellationToken.None))!.Order);
|
||||
Assert.Equal(1, (await store.GetContainerAsync("b_1", CancellationToken.None))!.Order);
|
||||
Assert.Equal(2, (await store.GetContainerAsync("b_2", CancellationToken.None))!.Order);
|
||||
Assert.Equal(0, (await store.Store.GetContainerAsync("b_3", CancellationToken.None))!.Order);
|
||||
Assert.Equal(1, (await store.Store.GetContainerAsync("b_1", CancellationToken.None))!.Order);
|
||||
Assert.Equal(2, (await store.Store.GetContainerAsync("b_2", CancellationToken.None))!.Order);
|
||||
}
|
||||
|
||||
// ─── Удаление контейнера ──────────────────────────────────────────────
|
||||
@@ -300,7 +299,7 @@ public sealed class ContainersServiceTests
|
||||
[Fact]
|
||||
public async Task Delete_MovesCardsToInbox_ReturnsMovedCount()
|
||||
{
|
||||
(ContainersService service, FakeKanjStore store, _) = Create();
|
||||
(ContainersService service, TestKanjStore store, _) = Create();
|
||||
ContainerDto container = await service.CreateAsync(new ContainerCreateDto("Middle"), CancellationToken.None);
|
||||
store.AddCard("c_1", container.Id);
|
||||
store.AddCard("c_2", container.Id);
|
||||
@@ -309,7 +308,7 @@ public sealed class ContainersServiceTests
|
||||
int moved = await service.DeleteAsync(container.Id, CancellationToken.None);
|
||||
|
||||
Assert.Equal(2, moved);
|
||||
Assert.Null(await store.GetContainerAsync(container.Id, CancellationToken.None)); // контейнер удалён
|
||||
Assert.Null(await store.Store.GetContainerAsync(container.Id, CancellationToken.None)); // контейнер удалён
|
||||
Assert.Contains(store.Cards, card => card.CardId == "c_1" && card.Col == "inbox" && card.IsNew);
|
||||
Assert.Contains(store.Cards, card => card.CardId == "c_2" && card.Col == "inbox" && card.IsNew);
|
||||
Assert.Contains(store.Cards, card => card.CardId == "c_inbox" && card.Col == "inbox"); // inbox не тронут
|
||||
@@ -340,7 +339,7 @@ public sealed class ContainersServiceTests
|
||||
[Fact]
|
||||
public async Task GetColState_ReturnsStoredColumnsAsIs()
|
||||
{
|
||||
(ContainersService service, _, FakeSettingsStore settings) = Create();
|
||||
(ContainersService service, _, TestSettingsStore settings) = Create();
|
||||
settings.Preload("colState", """{"inbox":{"collapsed":true},"trash":{"width":"lg"}}""");
|
||||
|
||||
IReadOnlyDictionary<string, ColumnStateDto> state = await service.GetColStateAsync(CancellationToken.None);
|
||||
@@ -353,7 +352,7 @@ public sealed class ContainersServiceTests
|
||||
[Fact]
|
||||
public async Task PatchColState_NewColumn_WritesStateAndReturnsIt()
|
||||
{
|
||||
(ContainersService service, _, FakeSettingsStore settings) = Create();
|
||||
(ContainersService service, _, TestSettingsStore settings) = Create();
|
||||
|
||||
ColumnStateDto result = await service.PatchColStateAsync(
|
||||
"inbox", new ColumnStateDto(Collapsed: true, Width: null), CancellationToken.None);
|
||||
@@ -368,7 +367,7 @@ public sealed class ContainersServiceTests
|
||||
[Fact]
|
||||
public async Task PatchColState_ExistingColumn_MergesPatchIntoCurrent()
|
||||
{
|
||||
(ContainersService service, _, FakeSettingsStore settings) = Create();
|
||||
(ContainersService service, _, TestSettingsStore settings) = Create();
|
||||
settings.Preload("colState", """{"inbox":{"collapsed":true}}""");
|
||||
|
||||
ColumnStateDto result = await service.PatchColStateAsync(
|
||||
@@ -380,7 +379,7 @@ public sealed class ContainersServiceTests
|
||||
[Fact]
|
||||
public async Task PatchColState_NullFields_KeepCurrentState()
|
||||
{
|
||||
(ContainersService service, _, FakeSettingsStore settings) = Create();
|
||||
(ContainersService service, _, TestSettingsStore settings) = Create();
|
||||
settings.Preload("colState", """{"archive":{"collapsed":true,"width":"sm"}}""");
|
||||
|
||||
ColumnStateDto result = await service.PatchColStateAsync(
|
||||
@@ -392,7 +391,7 @@ public sealed class ContainersServiceTests
|
||||
[Fact]
|
||||
public async Task PatchColState_EmptyPatch_KeepsOtherColumnsAndWritesEmptyEntry()
|
||||
{
|
||||
(ContainersService service, _, FakeSettingsStore settings) = Create();
|
||||
(ContainersService service, _, TestSettingsStore settings) = Create();
|
||||
settings.Preload("colState", """{"archive":{"collapsed":true}}""");
|
||||
|
||||
await service.PatchColStateAsync("inbox", new ColumnStateDto(Collapsed: null, Width: null), CancellationToken.None);
|
||||
@@ -408,7 +407,7 @@ public sealed class ContainersServiceTests
|
||||
[Fact]
|
||||
public async Task GetColState_CorruptJson_ReturnsEmpty()
|
||||
{
|
||||
(ContainersService service, _, FakeSettingsStore settings) = Create();
|
||||
(ContainersService service, _, TestSettingsStore settings) = Create();
|
||||
settings.Preload("colState", "{oops");
|
||||
|
||||
IReadOnlyDictionary<string, ColumnStateDto> state = await service.GetColStateAsync(CancellationToken.None);
|
||||
@@ -418,11 +417,11 @@ public sealed class ContainersServiceTests
|
||||
|
||||
// ─── Хелперы ──────────────────────────────────────────────────────────
|
||||
|
||||
private static (ContainersService Service, FakeKanjStore Store, FakeSettingsStore Settings) Create()
|
||||
private static (ContainersService Service, TestKanjStore Store, TestSettingsStore Settings) Create()
|
||||
{
|
||||
var store = new FakeKanjStore();
|
||||
var settings = new FakeSettingsStore();
|
||||
return (new ContainersService(store, settings), store, settings);
|
||||
var store = new TestKanjStore();
|
||||
var settings = new TestSettingsStore();
|
||||
return (new ContainersService(store.Store, settings.Store), store, settings);
|
||||
}
|
||||
|
||||
// Патч только с нужными полями: остальные параметры null = «не менять».
|
||||
|
||||
@@ -3,8 +3,8 @@ using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using Deal.Modules.Settings.Application.Services;
|
||||
using Deal.Tests.Unit.Modules.Cards;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
|
||||
namespace Deal.Tests.Unit.Support;
|
||||
@@ -19,7 +19,7 @@ public sealed class ConversionRecomputerTests
|
||||
[Fact]
|
||||
public async Task Recompute_MockRates_ConvertsUsdBudgetToRub()
|
||||
{
|
||||
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create();
|
||||
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
|
||||
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
|
||||
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
|
||||
store.SeedCard(Card("l_inbox", KanbanColumns.Inbox, from: 50, to: 200, cur: "USD"));
|
||||
@@ -34,7 +34,7 @@ public sealed class ConversionRecomputerTests
|
||||
[Fact]
|
||||
public async Task Recompute_TargetCurrencyFromSettings_ConvertsToConfiguredCurrency()
|
||||
{
|
||||
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create();
|
||||
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
|
||||
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
|
||||
settings.Preload(SettingsKeys.TargetCurrency, "\"EUR\""); // PATCH пишет Upper (ApplyStringKey)
|
||||
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
|
||||
@@ -49,7 +49,7 @@ public sealed class ConversionRecomputerTests
|
||||
[Fact]
|
||||
public async Task Recompute_NoTargetCurrencySetting_DefaultsToRub()
|
||||
{
|
||||
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create();
|
||||
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
|
||||
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
|
||||
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
|
||||
|
||||
@@ -62,7 +62,7 @@ public sealed class ConversionRecomputerTests
|
||||
[Fact]
|
||||
public async Task Recompute_UsdtBudget_TreatedAsUsdRate()
|
||||
{
|
||||
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create();
|
||||
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
|
||||
var rates = new Dictionary<string, double> { ["RUB"] = 1.0, ["USD"] = 100.0, ["USDT"] = 90.0 };
|
||||
settings.Preload(SettingsKeys.RatesCache, CacheJson(rates));
|
||||
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USDT"));
|
||||
@@ -78,7 +78,7 @@ public sealed class ConversionRecomputerTests
|
||||
[Fact]
|
||||
public async Task Recompute_BudgetWithoutUpperBound_UsesLowerBoundForConvTo()
|
||||
{
|
||||
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create();
|
||||
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
|
||||
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
|
||||
store.SeedCard(Card("l_board", "b_1", from: 100, to: null, cur: "USD"));
|
||||
|
||||
@@ -93,7 +93,7 @@ public sealed class ConversionRecomputerTests
|
||||
[Fact]
|
||||
public async Task Recompute_ConversionOff_ReturnsZeroAndKeepsCardsUntouched()
|
||||
{
|
||||
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create();
|
||||
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
|
||||
settings.Preload(SettingsKeys.ConversionOn, "false");
|
||||
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
|
||||
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
|
||||
@@ -109,7 +109,7 @@ public sealed class ConversionRecomputerTests
|
||||
[Fact]
|
||||
public async Task Recompute_ExcludesArchiveAndTrashCards()
|
||||
{
|
||||
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create();
|
||||
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
|
||||
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
|
||||
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
|
||||
store.SeedCard(Card("l_archive", KanbanColumns.Archive, from: 100, to: 100, cur: "USD"));
|
||||
@@ -128,7 +128,7 @@ public sealed class ConversionRecomputerTests
|
||||
[Fact]
|
||||
public async Task Recompute_MissingCurrencyInRates_SkipsCardKeepingOldConversion()
|
||||
{
|
||||
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create();
|
||||
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
|
||||
settings.Preload(SettingsKeys.RatesCache, CacheJson(new Dictionary<string, double> { ["RUB"] = 1.0, ["USD"] = 92.5 }));
|
||||
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "XXX") with
|
||||
{
|
||||
@@ -147,7 +147,7 @@ public sealed class ConversionRecomputerTests
|
||||
[Fact]
|
||||
public async Task Recompute_NoRatesCache_ReturnsZeroAndKeepsCardsUntouched()
|
||||
{
|
||||
(ConversionRecomputer service, FakeKanjStore store, _) = Create();
|
||||
(ConversionRecomputer service, TestKanjStore store, _) = Create();
|
||||
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
|
||||
|
||||
int updated = await service.RecomputeAsync(CancellationToken.None);
|
||||
@@ -159,7 +159,7 @@ public sealed class ConversionRecomputerTests
|
||||
[Fact]
|
||||
public async Task Recompute_CorruptedRatesCache_ReturnsZero()
|
||||
{
|
||||
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create();
|
||||
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
|
||||
settings.Preload(SettingsKeys.RatesCache, "{это не JSON");
|
||||
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
|
||||
|
||||
@@ -174,7 +174,7 @@ public sealed class ConversionRecomputerTests
|
||||
[Fact]
|
||||
public async Task OnRatesChangedAsync_PerformsFullRecompute()
|
||||
{
|
||||
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create();
|
||||
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
|
||||
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
|
||||
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
|
||||
|
||||
@@ -189,7 +189,7 @@ public sealed class ConversionRecomputerTests
|
||||
[Fact]
|
||||
public async Task Recompute_SecondRun_RecomputesSameValues()
|
||||
{
|
||||
(ConversionRecomputer service, FakeKanjStore store, FakeSettingsStore settings) = Create();
|
||||
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
|
||||
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
|
||||
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
|
||||
|
||||
@@ -203,11 +203,11 @@ public sealed class ConversionRecomputerTests
|
||||
|
||||
// ─── Хелперы ────────────────────────────────────────────────────────────
|
||||
|
||||
private static (ConversionRecomputer Service, FakeKanjStore Store, FakeSettingsStore Settings) Create()
|
||||
private static (ConversionRecomputer Service, TestKanjStore Store, TestSettingsStore Settings) Create()
|
||||
{
|
||||
var store = new FakeKanjStore();
|
||||
var settings = new FakeSettingsStore();
|
||||
return (new ConversionRecomputer(settings, store), store, settings);
|
||||
var store = new TestKanjStore();
|
||||
var settings = new TestSettingsStore();
|
||||
return (new ConversionRecomputer(settings.Store, store.Store), store, settings);
|
||||
}
|
||||
|
||||
// Карточка с бюджетом в колонке (BudgetCur задан → Budget не null, как маппинг адаптера).
|
||||
@@ -237,7 +237,7 @@ public sealed class ConversionRecomputerTests
|
||||
// Конверсия (Converted) карточки в фейке после пересчёта.
|
||||
// store: Фейк-хранилище.
|
||||
// cardId: Id карточки.
|
||||
private static CardBudgetDto? ConvertedOf(FakeKanjStore store, string cardId) =>
|
||||
private static CardBudgetDto? ConvertedOf(TestKanjStore store, string cardId) =>
|
||||
store.CardDtos.Single(card => card.Id == cardId).Converted;
|
||||
|
||||
// Проверяет conv-поля карточки (From/To с допуском 0.001 и валюту).
|
||||
@@ -247,7 +247,7 @@ public sealed class ConversionRecomputerTests
|
||||
// convTo: Ожидаемая верхняя граница.
|
||||
// convCur: Ожидаемая валюта конверсии.
|
||||
private static void AssertConverted(
|
||||
FakeKanjStore store,
|
||||
TestKanjStore store,
|
||||
string cardId,
|
||||
double convFrom,
|
||||
double convTo,
|
||||
|
||||
@@ -15,7 +15,6 @@ using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Deal.Tests.Unit.Grpc;
|
||||
using Deal.Tests.Unit.Modules.Cards;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
using Deal.Tests.Unit.Modules.Tenants;
|
||||
using Microsoft.Extensions.Logging.Abstractions;
|
||||
@@ -52,12 +51,12 @@ public sealed class GrpcAiClassifierTests
|
||||
Reason = "реклама",
|
||||
Usage = new Usage { Prompt = 500, Completion = 40, Total = 540 },
|
||||
};
|
||||
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
|
||||
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
|
||||
settings.Preload(SettingsKeys.AiFilterPrompt, Json("Фильтруй. {domain} | {keywords}."));
|
||||
settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain));
|
||||
settings.Preload(SettingsKeys.DomainKeywords, Json(TestKeywords));
|
||||
|
||||
FakeTenantLimitStore limits = new();
|
||||
TestTenantLimitStore limits = new();
|
||||
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits);
|
||||
AiFilterResultDto result = await classifier.FilterAsync("Купите телеграм-канал", CancellationToken.None);
|
||||
|
||||
@@ -88,7 +87,7 @@ public sealed class GrpcAiClassifierTests
|
||||
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
|
||||
{
|
||||
service.FilterUnavailable = true;
|
||||
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
|
||||
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
|
||||
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
|
||||
|
||||
await Assert.ThrowsAsync<AiUnavailableException>(
|
||||
@@ -102,7 +101,7 @@ public sealed class GrpcAiClassifierTests
|
||||
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
|
||||
{
|
||||
string text = new string('а', 5000);
|
||||
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
|
||||
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
|
||||
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
|
||||
|
||||
await classifier.FilterAsync(text, CancellationToken.None);
|
||||
@@ -131,7 +130,7 @@ public sealed class GrpcAiClassifierTests
|
||||
""",
|
||||
Usage = new Usage { Prompt = 3000, Completion = 700, Total = 3700 },
|
||||
};
|
||||
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
|
||||
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
|
||||
settings.Preload(SettingsKeys.AiPrompt, Json("Разбор: {domain}."));
|
||||
settings.Preload(SettingsKeys.CardPrompt, Json("Верни блок «О заявке»."));
|
||||
settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain));
|
||||
@@ -152,7 +151,7 @@ public sealed class GrpcAiClassifierTests
|
||||
Order = 0,
|
||||
});
|
||||
|
||||
FakeTenantLimitStore limits = new();
|
||||
TestTenantLimitStore limits = new();
|
||||
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits);
|
||||
AiParsedCardDto parsed = await classifier.ClassifyAsync("Нужен Python-разработчик, оплата от 2000$", CancellationToken.None);
|
||||
|
||||
@@ -191,9 +190,9 @@ public sealed class GrpcAiClassifierTests
|
||||
Ok = false,
|
||||
Usage = new Usage { Prompt = 900, Completion = 0, Total = 900 },
|
||||
};
|
||||
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
|
||||
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
|
||||
|
||||
FakeTenantLimitStore limits = new();
|
||||
TestTenantLimitStore limits = new();
|
||||
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits);
|
||||
|
||||
// ok=false (модель без JSON) → AiUnavailableException: воркер падает в локальный разбор (aiFail).
|
||||
@@ -212,7 +211,7 @@ public sealed class GrpcAiClassifierTests
|
||||
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
|
||||
{
|
||||
service.ClassifyUnavailable = true;
|
||||
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
|
||||
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
|
||||
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
|
||||
|
||||
await Assert.ThrowsAsync<AiUnavailableException>(
|
||||
@@ -226,7 +225,7 @@ public sealed class GrpcAiClassifierTests
|
||||
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
|
||||
{
|
||||
service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Заголовок","stack":[],"is_spam":false}""" };
|
||||
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
|
||||
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
|
||||
settings.Preload(SettingsKeys.AiProvider, Json("anthropic"));
|
||||
settings.Preload(
|
||||
SettingsKeys.AiConfigs,
|
||||
@@ -263,7 +262,7 @@ public sealed class GrpcAiClassifierTests
|
||||
{
|
||||
service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Т","stack":[],"is_spam":false}""" };
|
||||
string text = new string('б', 6000);
|
||||
(FakeSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
|
||||
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
|
||||
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
|
||||
|
||||
await classifier.ClassifyAsync(text, CancellationToken.None);
|
||||
@@ -284,28 +283,29 @@ public sealed class GrpcAiClassifierTests
|
||||
// Возвращает: Экземпляр GrpcAiClassifier.
|
||||
private static GrpcAiClassifier CreateClassifier(
|
||||
int port,
|
||||
FakeSettingsStore settings,
|
||||
TestSettingsStore settings,
|
||||
ISecretCipher cipher,
|
||||
FakeKanjStore kanjStore,
|
||||
FakeTenantLimitStore? limits = null)
|
||||
TestKanjStore kanjStore,
|
||||
TestTenantLimitStore? limits = null)
|
||||
{
|
||||
limits ??= new TestTenantLimitStore();
|
||||
ITenantContext tenantContext = new TenantContext();
|
||||
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
||||
var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
|
||||
return new GrpcAiClassifier(
|
||||
tenantContext,
|
||||
connection,
|
||||
new AiProviderConfigBuilder(settings, cipher),
|
||||
new AiClassifyContextBuilder(settings, kanjStore),
|
||||
new TokenUsageRecorder(settings, limits ?? new FakeTenantLimitStore(), tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
|
||||
new AiProviderConfigBuilder(settings.Store, cipher),
|
||||
new AiClassifyContextBuilder(settings.Store, kanjStore.Store),
|
||||
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
|
||||
NullLogger<GrpcAiClassifier>.Instance);
|
||||
}
|
||||
|
||||
// Создаёт контекст сценария (пустые фейки; сценарий переопределяет настройки/доски).
|
||||
// port: Порт хоста (не используется контекстом — единый вид хелперов).
|
||||
// Возвращает: Кортеж фейков (настройки, шифр, канбан).
|
||||
private static (FakeSettingsStore Settings, ISecretCipher Cipher, FakeKanjStore Kanj) Context(int port)
|
||||
=> (new FakeSettingsStore(), TestCiphers.New(), new FakeKanjStore());
|
||||
private static (TestSettingsStore Settings, ISecretCipher Cipher, TestKanjStore Kanj) Context(int port)
|
||||
=> (new TestSettingsStore(), TestCiphers.New(), new TestKanjStore());
|
||||
|
||||
// Сериализует значение настройки в JSON-строку (как пишет SettingsStore).
|
||||
// value: Значение (строка/список).
|
||||
@@ -313,7 +313,7 @@ public sealed class GrpcAiClassifierTests
|
||||
private static string Json(object value) => JsonSerializer.Serialize(value);
|
||||
|
||||
private static async Task AssertUsageAsync(
|
||||
FakeSettingsStore settings,
|
||||
TestSettingsStore settings,
|
||||
uint prompt,
|
||||
uint completion,
|
||||
uint total)
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
|
||||
namespace Deal.Tests.Unit.Support;
|
||||
@@ -12,7 +13,7 @@ public sealed class IncomingRulesTests
|
||||
// Создаёт правила поверх in-memory KV-хранилища.
|
||||
// store: Хранилище настроек тенанта.
|
||||
// Возвращает: Экземпляр IncomingRules.
|
||||
private static IncomingRules CreateRules(FakeSettingsStore store) => new(store);
|
||||
private static IncomingRules CreateRules(TestSettingsStore store) => new(store.Store);
|
||||
|
||||
// Сериализует значение в JSON-строку строки settings (как пишет SettingsStore).
|
||||
// value: Значение (строка/число/булево/список).
|
||||
@@ -23,7 +24,7 @@ public sealed class IncomingRulesTests
|
||||
[Fact]
|
||||
public async Task CheckAsync_ShortTextBelowDefaultMinLen_FailsWithKindLength()
|
||||
{
|
||||
IncomingRulesResult result = await CreateRules(new FakeSettingsStore())
|
||||
IncomingRulesResult result = await CreateRules(new TestSettingsStore())
|
||||
.CheckAsync("Короткий текст", CancellationToken.None);
|
||||
|
||||
Assert.False(result.Pass);
|
||||
@@ -37,7 +38,7 @@ public sealed class IncomingRulesTests
|
||||
public async Task CheckAsync_EmptyText_IsLengthFail()
|
||||
{
|
||||
// (text or "").strip() — пустой/пробельный текст всегда «короче minLen».
|
||||
IncomingRulesResult result = await CreateRules(new FakeSettingsStore())
|
||||
IncomingRulesResult result = await CreateRules(new TestSettingsStore())
|
||||
.CheckAsync(" ", CancellationToken.None);
|
||||
|
||||
Assert.False(result.Pass);
|
||||
@@ -47,7 +48,7 @@ public sealed class IncomingRulesTests
|
||||
[Fact]
|
||||
public async Task CheckAsync_StoredMinLenShorter_AllowsTextBetweenDefaultsAndOverride()
|
||||
{
|
||||
var store = new FakeSettingsStore();
|
||||
var store = new TestSettingsStore();
|
||||
store.Preload(SettingsKeys.MinLen, Json(10));
|
||||
IncomingRules rules = CreateRules(store);
|
||||
|
||||
@@ -61,7 +62,7 @@ public sealed class IncomingRulesTests
|
||||
[Fact]
|
||||
public async Task CheckAsync_LongCleanTextWithDefaults_Passes()
|
||||
{
|
||||
IncomingRulesResult result = await CreateRules(new FakeSettingsStore()).CheckAsync(
|
||||
IncomingRulesResult result = await CreateRules(new TestSettingsStore()).CheckAsync(
|
||||
"Заработок на крипте 300% в месяц! Подпишись на канал и получи бесплатный курс по трейдингу",
|
||||
CancellationToken.None);
|
||||
|
||||
@@ -75,7 +76,7 @@ public sealed class IncomingRulesTests
|
||||
[Fact]
|
||||
public async Task CheckAsync_StopPhraseHit_ReturnsPhraseAsKwAndReason()
|
||||
{
|
||||
var store = new FakeSettingsStore();
|
||||
var store = new TestSettingsStore();
|
||||
store.Preload(SettingsKeys.StopPhrases, Json(new[] { "взаимный пиар" }));
|
||||
IncomingRules rules = CreateRules(store);
|
||||
|
||||
@@ -92,7 +93,7 @@ public sealed class IncomingRulesTests
|
||||
[Fact]
|
||||
public async Task CheckAsync_StopPhrase_CaseInsensitiveMatch_KwKeepsStoredCase()
|
||||
{
|
||||
var store = new FakeSettingsStore();
|
||||
var store = new TestSettingsStore();
|
||||
store.Preload(SettingsKeys.StopPhrases, Json(new[] { "Взаимный Пиар" }));
|
||||
IncomingRules rules = CreateRules(store);
|
||||
|
||||
@@ -108,7 +109,7 @@ public sealed class IncomingRulesTests
|
||||
[Fact]
|
||||
public async Task CheckAsync_ResumeMarker_BlockResumesOn_BlocksWithMarker()
|
||||
{
|
||||
var store = new FakeSettingsStore();
|
||||
var store = new TestSettingsStore();
|
||||
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
|
||||
IncomingRules rules = CreateRules(store);
|
||||
|
||||
@@ -125,7 +126,7 @@ public sealed class IncomingRulesTests
|
||||
[Fact]
|
||||
public async Task CheckAsync_ResumeText_BlockResumesOff_Passes()
|
||||
{
|
||||
var store = new FakeSettingsStore();
|
||||
var store = new TestSettingsStore();
|
||||
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
|
||||
store.Preload(SettingsKeys.BlockResumes, Json(false));
|
||||
IncomingRules rules = CreateRules(store);
|
||||
@@ -141,7 +142,7 @@ public sealed class IncomingRulesTests
|
||||
[Fact]
|
||||
public async Task CheckAsync_ResumeWordWithoutHireMarkerBefore_IsCut()
|
||||
{
|
||||
var store = new FakeSettingsStore();
|
||||
var store = new TestSettingsStore();
|
||||
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
|
||||
IncomingRules rules = CreateRules(store);
|
||||
|
||||
@@ -158,7 +159,7 @@ public sealed class IncomingRulesTests
|
||||
[Fact]
|
||||
public async Task CheckAsync_VacancyWithResumeWordAfterHireMarker_GuardAllowsPass()
|
||||
{
|
||||
var store = new FakeSettingsStore();
|
||||
var store = new TestSettingsStore();
|
||||
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
|
||||
IncomingRules rules = CreateRules(store);
|
||||
|
||||
@@ -175,7 +176,7 @@ public sealed class IncomingRulesTests
|
||||
[Fact]
|
||||
public async Task CheckAsync_WantedTypeFreelance_VacancyLikeText_BlockedAsType()
|
||||
{
|
||||
var store = new FakeSettingsStore();
|
||||
var store = new TestSettingsStore();
|
||||
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
|
||||
store.Preload(SettingsKeys.WantedType, Json("freelance"));
|
||||
IncomingRules rules = CreateRules(store);
|
||||
@@ -194,7 +195,7 @@ public sealed class IncomingRulesTests
|
||||
[Fact]
|
||||
public async Task CheckAsync_WantedTypeVacancy_OrderLikeText_BlockedAsType()
|
||||
{
|
||||
var store = new FakeSettingsStore();
|
||||
var store = new TestSettingsStore();
|
||||
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
|
||||
store.Preload(SettingsKeys.WantedType, Json("vacancy"));
|
||||
IncomingRules rules = CreateRules(store);
|
||||
@@ -211,7 +212,7 @@ public sealed class IncomingRulesTests
|
||||
[Fact]
|
||||
public async Task CheckAsync_WantedTypeFreelance_OrderText_Passes()
|
||||
{
|
||||
var store = new FakeSettingsStore();
|
||||
var store = new TestSettingsStore();
|
||||
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
|
||||
store.Preload(SettingsKeys.WantedType, Json("freelance"));
|
||||
IncomingRules rules = CreateRules(store);
|
||||
@@ -229,7 +230,7 @@ public sealed class IncomingRulesTests
|
||||
public async Task CheckAsync_WantedTypeBoth_VacancyText_Passes()
|
||||
{
|
||||
// Дефолт wantedType=both: и вакансии, и заказы — вакансионный текст не отсекается.
|
||||
var store = new FakeSettingsStore();
|
||||
var store = new TestSettingsStore();
|
||||
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
|
||||
IncomingRules rules = CreateRules(store);
|
||||
|
||||
|
||||
@@ -37,10 +37,10 @@ public sealed class JoinEndpointHttpTests
|
||||
{
|
||||
var inviteStore = new TestInviteStore();
|
||||
inviteStore.AddInvite(NewInvite(Email));
|
||||
var authStore = new FakeAuthStore();
|
||||
var authStore = new TestAuthStore();
|
||||
var tenantStore = new TestTenantStore();
|
||||
var provisioner = new TestTenantProvisioner();
|
||||
var auditStore = new FakeAuditLogStore();
|
||||
var auditStore = new TestAuditLogStore();
|
||||
|
||||
await RunAsync(
|
||||
inviteStore, tenantStore, provisioner.Provisioner, authStore, auditStore,
|
||||
@@ -56,7 +56,7 @@ public sealed class JoinEndpointHttpTests
|
||||
Assert.False(response.Headers.Contains("Set-Cookie"));
|
||||
});
|
||||
|
||||
// Пользователь создан (login=email, хэш через фейк-хэшер), тенант создан и провижинен один раз, инвайт activated.
|
||||
// Пользователь создан (login=email, хэш через подставку-хэшер), тенант создан и провижинен один раз, инвайт activated.
|
||||
StoredUserDto user = Assert.Single(authStore.Users);
|
||||
Assert.Equal(Email, user.Login);
|
||||
Assert.Equal(TestHashers.New().Hash(Password), user.PasswordHash);
|
||||
@@ -80,10 +80,10 @@ public sealed class JoinEndpointHttpTests
|
||||
{
|
||||
var inviteStore = new TestInviteStore();
|
||||
inviteStore.AddInvite(NewInvite(Email));
|
||||
var authStore = new FakeAuthStore();
|
||||
var authStore = new TestAuthStore();
|
||||
|
||||
await RunAsync(
|
||||
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(),
|
||||
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new TestAuditLogStore(),
|
||||
async (baseAddress, client) =>
|
||||
{
|
||||
using (HttpResponseMessage first = await PostJsonAsync(
|
||||
@@ -108,10 +108,10 @@ public sealed class JoinEndpointHttpTests
|
||||
{
|
||||
var inviteStore = new TestInviteStore();
|
||||
inviteStore.AddInvite(NewInvite(Email));
|
||||
var authStore = new FakeAuthStore();
|
||||
var authStore = new TestAuthStore();
|
||||
|
||||
await RunAsync(
|
||||
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(),
|
||||
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new TestAuditLogStore(),
|
||||
async (baseAddress, client) =>
|
||||
{
|
||||
using HttpResponseMessage response = await PostJsonAsync(
|
||||
@@ -132,7 +132,7 @@ public sealed class JoinEndpointHttpTests
|
||||
inviteStore.AddInvite(NewInvite(Email, status: InviteStatuses.Revoked));
|
||||
|
||||
await RunAsync(
|
||||
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(),
|
||||
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new TestAuthStore(), new TestAuditLogStore(),
|
||||
async (baseAddress, client) =>
|
||||
{
|
||||
using HttpResponseMessage response = await PostJsonAsync(
|
||||
@@ -150,7 +150,7 @@ public sealed class JoinEndpointHttpTests
|
||||
inviteStore.AddInvite(NewInvite(Email, expiresAt: DateTimeOffset.UtcNow.AddHours(-1)));
|
||||
|
||||
await RunAsync(
|
||||
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(),
|
||||
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new TestAuthStore(), new TestAuditLogStore(),
|
||||
async (baseAddress, client) =>
|
||||
{
|
||||
using HttpResponseMessage response = await PostJsonAsync(
|
||||
@@ -165,7 +165,7 @@ public sealed class JoinEndpointHttpTests
|
||||
public async Task Join_WithUnknownCode_Returns400NotFound()
|
||||
{
|
||||
await RunAsync(
|
||||
new TestInviteStore(), new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(),
|
||||
new TestInviteStore(), new TestTenantStore(), new TestTenantProvisioner().Provisioner, new TestAuthStore(), new TestAuditLogStore(),
|
||||
async (baseAddress, client) =>
|
||||
{
|
||||
using HttpResponseMessage response = await PostJsonAsync(
|
||||
@@ -183,7 +183,7 @@ public sealed class JoinEndpointHttpTests
|
||||
inviteStore.AddInvite(NewInvite(Email));
|
||||
|
||||
await RunAsync(
|
||||
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new FakeAuthStore(), new FakeAuditLogStore(),
|
||||
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, new TestAuthStore(), new TestAuditLogStore(),
|
||||
async (baseAddress, client) =>
|
||||
{
|
||||
using HttpResponseMessage response = await PostJsonAsync(
|
||||
@@ -201,11 +201,11 @@ public sealed class JoinEndpointHttpTests
|
||||
{
|
||||
var inviteStore = new TestInviteStore();
|
||||
inviteStore.AddInvite(NewInvite(Email));
|
||||
var authStore = new FakeAuthStore();
|
||||
var authStore = new TestAuthStore();
|
||||
authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: "active", "hash"));
|
||||
|
||||
await RunAsync(
|
||||
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(),
|
||||
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new TestAuditLogStore(),
|
||||
async (baseAddress, client) =>
|
||||
{
|
||||
using HttpResponseMessage response = await PostJsonAsync(
|
||||
@@ -228,10 +228,10 @@ public sealed class JoinEndpointHttpTests
|
||||
// Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему.
|
||||
var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow));
|
||||
var provisioner = new TestTenantProvisioner();
|
||||
var authStore = new FakeAuthStore();
|
||||
var authStore = new TestAuthStore();
|
||||
|
||||
await RunAsync(
|
||||
inviteStore, tenantStore, provisioner.Provisioner, authStore, new FakeAuditLogStore(),
|
||||
inviteStore, tenantStore, provisioner.Provisioner, authStore, new TestAuditLogStore(),
|
||||
async (baseAddress, client) =>
|
||||
{
|
||||
using HttpResponseMessage response = await PostJsonAsync(
|
||||
@@ -247,13 +247,13 @@ public sealed class JoinEndpointHttpTests
|
||||
|
||||
// ─── Хелперы ─────────────────────────────────────────────────────────
|
||||
|
||||
// Поднимает in-process Kestrel с /api/join на фейк-хранилищах и прогоняет сценарий.
|
||||
// Поднимает in-process Kestrel с /api/join на подставках хранилищ и прогоняет сценарий.
|
||||
private static async Task RunAsync(
|
||||
TestInviteStore inviteStore,
|
||||
TestTenantStore tenantStore,
|
||||
ITenantProvisioner provisioner,
|
||||
FakeAuthStore authStore,
|
||||
FakeAuditLogStore auditStore,
|
||||
TestAuthStore authStore,
|
||||
TestAuditLogStore auditStore,
|
||||
Func<string, HttpClient, Task> scenario)
|
||||
{
|
||||
int port = TestPort.Allocate();
|
||||
@@ -264,8 +264,8 @@ public sealed class JoinEndpointHttpTests
|
||||
// Хэшер-подставка регистрируется ПОСЛЕ AddTenantsModule (DefaultPasswordHasher) — побеждает
|
||||
// последняя регистрация (зеркало OperatorAuthHttpHost).
|
||||
builder.Services.AddSingleton(TestHashers.New());
|
||||
builder.Services.AddSingleton<IAuthStore>(authStore);
|
||||
builder.Services.AddSingleton<IAuditLogStore>(auditStore);
|
||||
builder.Services.AddSingleton<IAuthStore>(authStore.Store);
|
||||
builder.Services.AddSingleton<IAuditLogStore>(auditStore.Store);
|
||||
builder.Services.AddSingleton<IInviteStore>(inviteStore.Store);
|
||||
builder.Services.AddSingleton<ITenantRepository>(tenantStore.Repository);
|
||||
builder.Services.AddSingleton<ITenantProvisioner>(provisioner);
|
||||
|
||||
@@ -5,7 +5,7 @@ using Deal.Modules.Cards.Application.Sources;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Tests.Unit.Modules.Cards;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
|
||||
namespace Deal.Tests.Unit.Support;
|
||||
@@ -17,18 +17,18 @@ public sealed class LocalColumnSuggesterTests
|
||||
{
|
||||
// Создаёт контекст теста: in-memory хранилища + адаптер поверх сервиса досок модуля.
|
||||
// Возвращает: Кортеж (хранилище канбана, KV-настройки, адаптер).
|
||||
private static (FakeKanjStore Store, FakeSettingsStore Settings, LocalColumnSuggester Suggester) CreateContext()
|
||||
private static (TestKanjStore Store, TestSettingsStore Settings, LocalColumnSuggester Suggester) CreateContext()
|
||||
{
|
||||
var store = new FakeKanjStore();
|
||||
var settings = new FakeSettingsStore();
|
||||
var suggester = new LocalColumnSuggester(store, settings, new ContainersService(store, settings));
|
||||
var store = new TestKanjStore();
|
||||
var settings = new TestSettingsStore();
|
||||
var suggester = new LocalColumnSuggester(store.Store, settings.Store, new ContainersService(store.Store, settings.Store));
|
||||
return (store, settings, suggester);
|
||||
}
|
||||
|
||||
private static void SeedInbox(
|
||||
string id,
|
||||
string text,
|
||||
FakeKanjStore store,
|
||||
TestKanjStore store,
|
||||
long receivedAtMs = 0) =>
|
||||
store.SeedCard(new CardDto
|
||||
{
|
||||
@@ -57,7 +57,7 @@ public sealed class LocalColumnSuggesterTests
|
||||
[Fact]
|
||||
public async Task SuggestColumns_TooFewCards_ReturnsTooFewCardsReason()
|
||||
{
|
||||
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
||||
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
||||
for (int i = 1; i < SuggestHeuristics.MinInbox; i++)
|
||||
{
|
||||
SeedInbox($"l_{i}", "нужен python", store);
|
||||
@@ -72,7 +72,7 @@ public sealed class LocalColumnSuggesterTests
|
||||
[Fact]
|
||||
public async Task SuggestColumns_CooldownActive_ReturnsCooldownReason()
|
||||
{
|
||||
(FakeKanjStore store, FakeSettingsStore settings, LocalColumnSuggester suggester) = CreateContext();
|
||||
(TestKanjStore store, TestSettingsStore settings, LocalColumnSuggester suggester) = CreateContext();
|
||||
settings.Preload(SettingsKeys.LastSuggestAt,
|
||||
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
|
||||
SeedInbox("l_1", "нужен python", store);
|
||||
@@ -87,7 +87,7 @@ public sealed class LocalColumnSuggesterTests
|
||||
[Fact]
|
||||
public async Task SuggestColumns_NoThemes_ReturnsNothingGroupedReason()
|
||||
{
|
||||
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
||||
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
||||
string[] uniqueWords = ["python", "vue", "crm", "sql", "php", "mvp"];
|
||||
for (int i = 0; i < uniqueWords.Length; i++)
|
||||
{
|
||||
@@ -103,7 +103,7 @@ public sealed class LocalColumnSuggesterTests
|
||||
[Fact]
|
||||
public async Task SuggestColumns_SimilarExistingBoard_ReturnsNothingGroupedReason()
|
||||
{
|
||||
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
||||
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
||||
store.SeedBoard(new ContainerDto
|
||||
{
|
||||
Id = "b_python",
|
||||
@@ -128,7 +128,7 @@ public sealed class LocalColumnSuggesterTests
|
||||
[Fact]
|
||||
public async Task SuggestColumns_Success_CreatesSuggestedBoardsAndPlacesCards()
|
||||
{
|
||||
(FakeKanjStore store, FakeSettingsStore settings, LocalColumnSuggester suggester) = CreateContext();
|
||||
(TestKanjStore store, TestSettingsStore settings, LocalColumnSuggester suggester) = CreateContext();
|
||||
SeedInbox("l_p1", "нужен python", store);
|
||||
SeedInbox("l_p2", "нужен python", store);
|
||||
SeedInbox("l_p3", "нужен python", store);
|
||||
@@ -169,7 +169,7 @@ public sealed class LocalColumnSuggesterTests
|
||||
[Fact]
|
||||
public async Task SuggestColumns_SuccessThenImmediateRepeat_BlockedByCooldown()
|
||||
{
|
||||
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
||||
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
||||
SeedInbox("l_p1", "нужен python", store);
|
||||
SeedInbox("l_p2", "нужен python", store);
|
||||
SeedInbox("l_p3", "нужен python", store);
|
||||
@@ -191,7 +191,7 @@ public sealed class LocalColumnSuggesterTests
|
||||
[Fact]
|
||||
public async Task SuggestKeywords_Success_ReturnsRepeatedWordMarkers()
|
||||
{
|
||||
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
||||
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
||||
SeedInbox("l_p1", "нужен python", store);
|
||||
SeedInbox("l_p2", "нужен python", store);
|
||||
SeedInbox("l_p3", "нужен python", store);
|
||||
@@ -209,7 +209,7 @@ public sealed class LocalColumnSuggesterTests
|
||||
[Fact]
|
||||
public async Task SuggestKeywords_TooFewCards_ReturnsReason()
|
||||
{
|
||||
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
||||
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
||||
SeedInbox("l_1", "нужен python", store);
|
||||
SeedInbox("l_2", "нужен python", store);
|
||||
|
||||
@@ -223,7 +223,7 @@ public sealed class LocalColumnSuggesterTests
|
||||
[Fact]
|
||||
public async Task SuggestKeywords_TrashAndArchiveCards_AreNotCounted()
|
||||
{
|
||||
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
||||
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
||||
SeedInbox("l_1", "нужен python", store);
|
||||
SeedInbox("l_2", "нужен vue", store);
|
||||
store.SeedCard(new CardDto { Id = "l_t1", Col = KanbanColumns.Trash, Content = new SourceContent { Text = "ищу такси" } });
|
||||
@@ -239,7 +239,7 @@ public sealed class LocalColumnSuggesterTests
|
||||
[Fact]
|
||||
public async Task SuggestKeywords_NoRepeatedMarkers_ReturnsReason()
|
||||
{
|
||||
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
||||
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
||||
string[] uniqueWords = ["python", "vue", "crm", "sql", "php", "mvp"];
|
||||
for (int i = 0; i < uniqueWords.Length; i++)
|
||||
{
|
||||
|
||||
@@ -3,6 +3,7 @@ using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Infrastructure.Integrations.Services;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using Deal.Tests.Unit.Modules.Kanban;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
|
||||
@@ -27,14 +28,17 @@ public sealed class LocalMlClientTests
|
||||
// store: Хранилище настроек тенанта.
|
||||
// learning: Хранилище обучения (по умолчанию пустое: счётчики 0).
|
||||
// Возвращает: Экземпляр LocalMlClient.
|
||||
private static LocalMlClient CreateClient(FakeSettingsStore store, FakeMlLearningStore? learning = null)
|
||||
=> new(store, learning ?? new FakeMlLearningStore());
|
||||
private static LocalMlClient CreateClient(TestSettingsStore store, TestMlLearningStore? learning = null)
|
||||
{
|
||||
learning ??= new TestMlLearningStore();
|
||||
return new(store.Store, learning.Store);
|
||||
}
|
||||
|
||||
|
||||
[Fact]
|
||||
public async Task StatusAsync_NoSettings_ReturnsDeterministicNotReadyStatus()
|
||||
{
|
||||
LocalMlClient client = CreateClient(new FakeSettingsStore());
|
||||
LocalMlClient client = CreateClient(new TestSettingsStore());
|
||||
|
||||
MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None);
|
||||
|
||||
@@ -63,7 +67,7 @@ public sealed class LocalMlClientTests
|
||||
[Fact]
|
||||
public async Task StatusAsync_StoredCounters_AreReflectedInStats()
|
||||
{
|
||||
var store = new FakeSettingsStore();
|
||||
var store = new TestSettingsStore();
|
||||
store.Preload(SettingsKeys.MlDecisions, "7");
|
||||
store.Preload(SettingsKeys.AiDecisions, "3");
|
||||
LocalMlClient client = CreateClient(store);
|
||||
@@ -78,7 +82,7 @@ public sealed class LocalMlClientTests
|
||||
[Fact]
|
||||
public async Task StatusAsync_StoredMlEnabledFalse_Disables()
|
||||
{
|
||||
var store = new FakeSettingsStore();
|
||||
var store = new TestSettingsStore();
|
||||
store.Preload(SettingsKeys.MlEnabled, "false");
|
||||
|
||||
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
|
||||
@@ -89,7 +93,7 @@ public sealed class LocalMlClientTests
|
||||
[Fact]
|
||||
public async Task StatusAsync_StoredMlEnabledTrue_Enables()
|
||||
{
|
||||
var store = new FakeSettingsStore();
|
||||
var store = new TestSettingsStore();
|
||||
store.Preload(SettingsKeys.MlEnabled, "true");
|
||||
|
||||
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
|
||||
@@ -102,7 +106,7 @@ public sealed class LocalMlClientTests
|
||||
[InlineData("не json {{")]
|
||||
public async Task StatusAsync_NonBoolOrDamagedMlEnabled_DegradesToDefault(string valueJson)
|
||||
{
|
||||
var store = new FakeSettingsStore();
|
||||
var store = new TestSettingsStore();
|
||||
store.Preload(SettingsKeys.MlEnabled, valueJson);
|
||||
|
||||
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
|
||||
@@ -113,7 +117,7 @@ public sealed class LocalMlClientTests
|
||||
[Fact]
|
||||
public async Task StatusAsync_DamagedCounter_DegradesToZero()
|
||||
{
|
||||
var store = new FakeSettingsStore();
|
||||
var store = new TestSettingsStore();
|
||||
store.Preload(SettingsKeys.MlDecisions, "не число");
|
||||
|
||||
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
|
||||
@@ -124,8 +128,8 @@ public sealed class LocalMlClientTests
|
||||
[Fact]
|
||||
public async Task StatusAsync_ReportsLearningAndOutboxFromTables()
|
||||
{
|
||||
var store = new FakeSettingsStore();
|
||||
var learning = new FakeMlLearningStore { LearningCount = 3 }; // журнал CardMoves: 3 записи
|
||||
var store = new TestSettingsStore();
|
||||
var learning = new TestMlLearningStore { LearningCount = 3 }; // журнал CardMoves: 3 записи
|
||||
LocalMlClient client = CreateClient(store, learning);
|
||||
await client.PushAsync("Разработчик Python, готов к проекту", "b_alpha", 1.0, CancellationToken.None);
|
||||
await client.PushAsync("Спам: купите курсы", "spam", -1.0, CancellationToken.None);
|
||||
@@ -142,7 +146,7 @@ public sealed class LocalMlClientTests
|
||||
[Fact]
|
||||
public async Task PredictAsync_AnyText_ReturnsFixedNotReadyShape()
|
||||
{
|
||||
LocalMlClient client = CreateClient(new FakeSettingsStore());
|
||||
LocalMlClient client = CreateClient(new TestSettingsStore());
|
||||
|
||||
MlPredictResultDto result = await client.PredictAsync(
|
||||
"Python backend на fastapi, бот в телеграм", CancellationToken.None);
|
||||
@@ -161,8 +165,8 @@ public sealed class LocalMlClientTests
|
||||
[Fact]
|
||||
public async Task PushAsync_WritesOutboxRow_WithMleIdAndTrimmedFields()
|
||||
{
|
||||
var learning = new FakeMlLearningStore();
|
||||
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning);
|
||||
var learning = new TestMlLearningStore();
|
||||
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
|
||||
|
||||
await client.PushAsync(" Python-разработчик, готов к работе ", " b_alpha ", 1.0, CancellationToken.None);
|
||||
|
||||
@@ -181,8 +185,8 @@ public sealed class LocalMlClientTests
|
||||
[InlineData("текст", " ")] // label из пробелов — пуста после trim
|
||||
public async Task PushAsync_EmptyTextOrLabelAfterTrim_IsNoOp(string text, string label)
|
||||
{
|
||||
var learning = new FakeMlLearningStore();
|
||||
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning);
|
||||
var learning = new TestMlLearningStore();
|
||||
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
|
||||
|
||||
await client.PushAsync(text, label, 1.0, CancellationToken.None);
|
||||
|
||||
@@ -192,8 +196,8 @@ public sealed class LocalMlClientTests
|
||||
[Fact]
|
||||
public async Task PushAsync_TextOver6000_IsTruncatedToMaxLength()
|
||||
{
|
||||
var learning = new FakeMlLearningStore();
|
||||
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning);
|
||||
var learning = new TestMlLearningStore();
|
||||
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
|
||||
string longText = new string('а', MaxLearningTextLength + 1000);
|
||||
|
||||
await client.PushAsync(longText, "spam", 1.0, CancellationToken.None);
|
||||
@@ -206,8 +210,8 @@ public sealed class LocalMlClientTests
|
||||
[Fact]
|
||||
public async Task PushAsync_TruncationDoesNotSplitSurrogatePair()
|
||||
{
|
||||
var learning = new FakeMlLearningStore();
|
||||
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning);
|
||||
var learning = new TestMlLearningStore();
|
||||
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
|
||||
// Длина 6001 в UTF-16: 5999 'x' + «🙂» (суррогатная пара) — срез .NET на 6000 попадает в пару.
|
||||
string text = new string('x', MaxLearningTextLength - 1) + "🙂";
|
||||
|
||||
@@ -222,8 +226,8 @@ public sealed class LocalMlClientTests
|
||||
[Fact]
|
||||
public async Task PushAsync_NegativeDelta_IsStoredAsIs()
|
||||
{
|
||||
var learning = new FakeMlLearningStore();
|
||||
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning);
|
||||
var learning = new TestMlLearningStore();
|
||||
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
|
||||
|
||||
await client.PushAsync("Это не спам — вернул из корзины", "spam", -1.0, CancellationToken.None);
|
||||
|
||||
@@ -236,7 +240,7 @@ public sealed class LocalMlClientTests
|
||||
[Fact]
|
||||
public async Task ResetAsync_ReturnsOk()
|
||||
{
|
||||
MlResetResultDto result = await CreateClient(new FakeSettingsStore()).ResetAsync(CancellationToken.None);
|
||||
MlResetResultDto result = await CreateClient(new TestSettingsStore()).ResetAsync(CancellationToken.None);
|
||||
|
||||
Assert.True(result.Ok);
|
||||
Assert.Null(result.Error);
|
||||
@@ -245,7 +249,7 @@ public sealed class LocalMlClientTests
|
||||
[Fact]
|
||||
public async Task ResetAsync_DoesNotTouchSettingsRows()
|
||||
{
|
||||
var store = new FakeSettingsStore();
|
||||
var store = new TestSettingsStore();
|
||||
store.Preload(SettingsKeys.MlDecisions, "7");
|
||||
LocalMlClient client = CreateClient(store);
|
||||
|
||||
@@ -259,9 +263,9 @@ public sealed class LocalMlClientTests
|
||||
[Fact]
|
||||
public async Task ResetAsync_ClearsOutbox_KeepsLearningJournalAndKvCounters()
|
||||
{
|
||||
var store = new FakeSettingsStore();
|
||||
var store = new TestSettingsStore();
|
||||
store.Preload(SettingsKeys.MlDecisions, "7");
|
||||
var learning = new FakeMlLearningStore { LearningCount = 4 }; // журнал CardMoves: 4 записи
|
||||
var learning = new TestMlLearningStore { LearningCount = 4 }; // журнал CardMoves: 4 записи
|
||||
LocalMlClient client = CreateClient(store, learning);
|
||||
await client.PushAsync("текст 1", "b_alpha", 1.0, CancellationToken.None);
|
||||
await client.PushAsync("текст 2", "spam", -1.0, CancellationToken.None);
|
||||
@@ -278,7 +282,7 @@ public sealed class LocalMlClientTests
|
||||
[Fact]
|
||||
public async Task MlStatusResponseDto_SerializesToPrototypeWireFormat()
|
||||
{
|
||||
var store = new FakeSettingsStore();
|
||||
var store = new TestSettingsStore();
|
||||
store.Preload(SettingsKeys.MlEnabled, "false");
|
||||
store.Preload(SettingsKeys.MlDecisions, "7");
|
||||
store.Preload(SettingsKeys.AiDecisions, "3");
|
||||
@@ -316,7 +320,7 @@ public sealed class LocalMlClientTests
|
||||
[Fact]
|
||||
public async Task MlPredictResultDto_SerializesToPrototypeWireFormat()
|
||||
{
|
||||
MlPredictResultDto dto = await CreateClient(new FakeSettingsStore()).PredictAsync("текст", CancellationToken.None);
|
||||
MlPredictResultDto dto = await CreateClient(new TestSettingsStore()).PredictAsync("текст", CancellationToken.None);
|
||||
|
||||
string json = JsonSerializer.Serialize(dto, CamelCaseOptions);
|
||||
|
||||
@@ -329,7 +333,7 @@ public sealed class LocalMlClientTests
|
||||
[Fact]
|
||||
public async Task MlResetResultDto_Success_SerializesWithoutErrorKey()
|
||||
{
|
||||
MlResetResultDto dto = await CreateClient(new FakeSettingsStore()).ResetAsync(CancellationToken.None);
|
||||
MlResetResultDto dto = await CreateClient(new TestSettingsStore()).ResetAsync(CancellationToken.None);
|
||||
|
||||
string json = JsonSerializer.Serialize(dto, CamelCaseOptions);
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ public sealed class LoginAttemptEndpointHttpTests
|
||||
public async Task Login_AfterFiveWrongAttempts_Returns429WithBlockedDetail()
|
||||
{
|
||||
await OperatorAuthHttpHost.RunAsync(
|
||||
new FakeOperatorAuthStore(),
|
||||
new TestOperatorAuthStore(),
|
||||
NewUserStore(),
|
||||
async (baseAddress, _, users, _, _, _, _) =>
|
||||
{
|
||||
@@ -66,7 +66,7 @@ public sealed class LoginAttemptEndpointHttpTests
|
||||
public async Task Login_SuccessResetsCounter_FurtherFailuresNotBlocked()
|
||||
{
|
||||
await OperatorAuthHttpHost.RunAsync(
|
||||
new FakeOperatorAuthStore(),
|
||||
new TestOperatorAuthStore(),
|
||||
NewUserStore(),
|
||||
async (baseAddress, _, users, _, _, _, _) =>
|
||||
{
|
||||
@@ -108,7 +108,7 @@ public sealed class LoginAttemptEndpointHttpTests
|
||||
using var capture = new SecurityEventCapture();
|
||||
|
||||
await OperatorAuthHttpHost.RunAsync(
|
||||
new FakeOperatorAuthStore(),
|
||||
new TestOperatorAuthStore(),
|
||||
NewUserStore(),
|
||||
async (baseAddress, _, _, _, _, _, _) =>
|
||||
{
|
||||
@@ -136,10 +136,10 @@ public sealed class LoginAttemptEndpointHttpTests
|
||||
new() { Enabled = true };
|
||||
|
||||
// Фейк-хранилище пользователей с активным пользователем Login/Password.
|
||||
private static FakeAuthStore NewUserStore()
|
||||
private static TestAuthStore NewUserStore()
|
||||
{
|
||||
var passwordHasher = TestHashers.New();
|
||||
var store = new FakeAuthStore();
|
||||
var store = new TestAuthStore();
|
||||
store.AddUser(new StoredUserDto(
|
||||
Guid.NewGuid(),
|
||||
Login,
|
||||
|
||||
@@ -3,6 +3,7 @@ using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Parse;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
|
||||
namespace Deal.Tests.Unit.Support;
|
||||
@@ -428,10 +429,10 @@ public sealed class MessageParseCoreTests
|
||||
public async Task ParseAsync_ReadsMarkerOverridesFromSettings()
|
||||
{
|
||||
// Кастомные hireMarkers из хранилища перекрывают дефолты: «вакансия» больше не маркер, свой — маркер.
|
||||
var store = new FakeSettingsStore();
|
||||
var store = new TestSettingsStore();
|
||||
store.Preload(SettingsKeys.HireMarkers, JsonSerializer.Serialize(new[] { "нанимаем джунов" }));
|
||||
|
||||
LocalFieldsParser parser = new(store);
|
||||
LocalFieldsParser parser = new(store.Store);
|
||||
LocalParsedFields custom = await parser.ParseAsync("Срочно нанимаем джунов на проект", default);
|
||||
LocalParsedFields withoutMarker = await parser.ParseAsync("Вакансия Java-разработчика на проект", default);
|
||||
|
||||
@@ -442,7 +443,7 @@ public sealed class MessageParseCoreTests
|
||||
[Fact]
|
||||
public async Task ParseAsync_NoOverrides_UsesDefaults()
|
||||
{
|
||||
LocalFieldsParser parser = new(new FakeSettingsStore());
|
||||
LocalFieldsParser parser = new(new TestSettingsStore().Store);
|
||||
|
||||
LocalParsedFields result = await parser.ParseAsync("Вакансия в команду на проект", default);
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
|
||||
[Fact]
|
||||
public async Task Overview_ReturnsTenantsTokensEventsAndLoginCounters()
|
||||
{
|
||||
var auditStore = new FakeAuditLogStore();
|
||||
var auditStore = new TestAuditLogStore();
|
||||
await SeedAuditAsync(auditStore, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, ActiveTenant);
|
||||
await SeedAuditAsync(auditStore, AuditEvents.TenantLogout, AuditActorTypes.Tenant, ActiveTenant);
|
||||
await SeedAuditAsync(auditStore, AuditEvents.TenantLoginFailed, AuditActorTypes.Tenant, ActiveTenant);
|
||||
@@ -83,10 +83,10 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
|
||||
[Fact]
|
||||
public async Task Suspicious_ReturnsFindingsForFailedLoginBurst()
|
||||
{
|
||||
var auditStore = new FakeAuditLogStore();
|
||||
var auditStore = new TestAuditLogStore();
|
||||
for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerIpThreshold; i++)
|
||||
{
|
||||
await auditStore.AppendAsync(
|
||||
await auditStore.Store.AppendAsync(
|
||||
new AuditRecordDto(
|
||||
AuditEvents.TenantLoginFailed,
|
||||
AuditActorTypes.Tenant,
|
||||
@@ -179,7 +179,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
|
||||
[Fact]
|
||||
public async Task Activity_FiltersByActorIdAndPaginates()
|
||||
{
|
||||
var auditStore = new FakeAuditLogStore();
|
||||
var auditStore = new TestAuditLogStore();
|
||||
Guid actor = Guid.NewGuid();
|
||||
await SeedAuditAsync(auditStore, AuditEvents.CardCreated, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 30);
|
||||
await SeedAuditAsync(auditStore, AuditEvents.CardMoved, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 20);
|
||||
@@ -222,7 +222,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
|
||||
[Fact]
|
||||
public async Task Audit_FiltersByActorIdAndOffset()
|
||||
{
|
||||
var auditStore = new FakeAuditLogStore();
|
||||
var auditStore = new TestAuditLogStore();
|
||||
Guid actor = Guid.NewGuid();
|
||||
await SeedAuditAsync(auditStore, AuditEvents.CardCreated, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 30);
|
||||
await SeedAuditAsync(auditStore, AuditEvents.CardMoved, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 20);
|
||||
@@ -256,32 +256,32 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
|
||||
new TenantRecordDto(ActiveTenant, "active-tenant", TenantStatuses.Active, DateTimeOffset.UtcNow.AddDays(-2)),
|
||||
new TenantRecordDto(SuspendedTenant, "suspended-tenant", TenantStatuses.Suspended, DateTimeOffset.UtcNow.AddDays(-1)));
|
||||
|
||||
private static FakeOperatorAuthStore NewOperatorStore()
|
||||
private static TestOperatorAuthStore NewOperatorStore()
|
||||
{
|
||||
var passwordHasher = TestHashers.New();
|
||||
var store = new FakeOperatorAuthStore();
|
||||
var store = new TestOperatorAuthStore();
|
||||
store.AddOperator(new StoredOperatorDto(
|
||||
Guid.NewGuid(), OperatorLogin, "active", passwordHasher.Hash(OperatorPassword)));
|
||||
return store;
|
||||
}
|
||||
|
||||
private static FakeAuthStore NewUserStore()
|
||||
private static TestAuthStore NewUserStore()
|
||||
{
|
||||
var passwordHasher = TestHashers.New();
|
||||
var store = new FakeAuthStore();
|
||||
var store = new TestAuthStore();
|
||||
store.AddUser(new StoredUserDto(
|
||||
Guid.NewGuid(), UserLogin, ActiveTenant, "active", passwordHasher.Hash(UserPassword)));
|
||||
return store;
|
||||
}
|
||||
|
||||
private static Task SeedAuditAsync(
|
||||
FakeAuditLogStore store,
|
||||
TestAuditLogStore store,
|
||||
string eventType,
|
||||
string actorType,
|
||||
Guid? tenantId,
|
||||
Guid? actorId = null,
|
||||
int minutesAgo = 1) =>
|
||||
store.AppendAsync(
|
||||
store.Store.AppendAsync(
|
||||
new AuditRecordDto(
|
||||
eventType,
|
||||
actorType,
|
||||
|
||||
@@ -39,7 +39,7 @@ public sealed class OperatorAuditEndpointsHttpTests
|
||||
[Fact]
|
||||
public async Task OperatorLoginSuccess_WritesOperatorLoginOkAuditRecord()
|
||||
{
|
||||
var auditStore = new FakeAuditLogStore();
|
||||
var auditStore = new TestAuditLogStore();
|
||||
|
||||
await OperatorAuthHttpHost.RunAsync(
|
||||
NewOperatorStore(),
|
||||
@@ -65,7 +65,7 @@ public sealed class OperatorAuditEndpointsHttpTests
|
||||
[Fact]
|
||||
public async Task OperatorLoginFailure_WritesOperatorLoginFailedAuditRecord()
|
||||
{
|
||||
var auditStore = new FakeAuditLogStore();
|
||||
var auditStore = new TestAuditLogStore();
|
||||
|
||||
await OperatorAuthHttpHost.RunAsync(
|
||||
NewOperatorStore(),
|
||||
@@ -91,7 +91,7 @@ public sealed class OperatorAuditEndpointsHttpTests
|
||||
[Fact]
|
||||
public async Task TenantLoginSuccessAndFailure_WriteTenantAuditRecords()
|
||||
{
|
||||
var auditStore = new FakeAuditLogStore();
|
||||
var auditStore = new TestAuditLogStore();
|
||||
|
||||
await OperatorAuthHttpHost.RunAsync(
|
||||
NewOperatorStore(),
|
||||
@@ -137,9 +137,9 @@ public sealed class OperatorAuditEndpointsHttpTests
|
||||
[Fact]
|
||||
public async Task Audit_ReturnsItemsNewestFirstWithTotal()
|
||||
{
|
||||
var auditStore = new FakeAuditLogStore();
|
||||
var auditStore = new TestAuditLogStore();
|
||||
// Историческая запись со старым At — гарантированно последняя в выборке (проверка At DESC).
|
||||
await auditStore.AppendAsync(new AuditRecordDto(
|
||||
await auditStore.Store.AppendAsync(new AuditRecordDto(
|
||||
AuditEvents.TenantCreated,
|
||||
AuditActorTypes.Operator,
|
||||
ActorId: Guid.NewGuid(),
|
||||
@@ -177,7 +177,7 @@ public sealed class OperatorAuditEndpointsHttpTests
|
||||
[Fact]
|
||||
public async Task Audit_FiltersByEventTypeAndActorType()
|
||||
{
|
||||
var auditStore = new FakeAuditLogStore();
|
||||
var auditStore = new TestAuditLogStore();
|
||||
|
||||
await OperatorAuthHttpHost.RunAsync(
|
||||
NewOperatorStore(),
|
||||
@@ -218,7 +218,7 @@ public sealed class OperatorAuditEndpointsHttpTests
|
||||
[Fact]
|
||||
public async Task TenantLogout_WritesTenantLogoutAuditRecord()
|
||||
{
|
||||
var auditStore = new FakeAuditLogStore();
|
||||
var auditStore = new TestAuditLogStore();
|
||||
|
||||
await OperatorAuthHttpHost.RunAsync(
|
||||
NewOperatorStore(),
|
||||
@@ -251,7 +251,7 @@ public sealed class OperatorAuditEndpointsHttpTests
|
||||
[Fact]
|
||||
public async Task OperatorLogout_WritesOperatorLogoutAuditRecord()
|
||||
{
|
||||
var auditStore = new FakeAuditLogStore();
|
||||
var auditStore = new TestAuditLogStore();
|
||||
|
||||
await OperatorAuthHttpHost.RunAsync(
|
||||
NewOperatorStore(),
|
||||
@@ -279,10 +279,10 @@ public sealed class OperatorAuditEndpointsHttpTests
|
||||
// ─── Хелперы ─────────────────────────────────────────────────────────
|
||||
|
||||
// Фейк-хранилище оператора с активным operator/operator.
|
||||
private static FakeOperatorAuthStore NewOperatorStore()
|
||||
private static TestOperatorAuthStore NewOperatorStore()
|
||||
{
|
||||
var passwordHasher = TestHashers.New();
|
||||
var store = new FakeOperatorAuthStore();
|
||||
var store = new TestOperatorAuthStore();
|
||||
store.AddOperator(new StoredOperatorDto(
|
||||
Guid.NewGuid(),
|
||||
OperatorLogin,
|
||||
@@ -292,10 +292,10 @@ public sealed class OperatorAuditEndpointsHttpTests
|
||||
}
|
||||
|
||||
// Фейк-хранилище пользователей с admin/admin в дефолтном тенанте.
|
||||
private static FakeAuthStore NewUserStore()
|
||||
private static TestAuthStore NewUserStore()
|
||||
{
|
||||
var passwordHasher = TestHashers.New();
|
||||
var store = new FakeAuthStore();
|
||||
var store = new TestAuthStore();
|
||||
store.AddUser(new StoredUserDto(
|
||||
Guid.NewGuid(),
|
||||
UserLogin,
|
||||
|
||||
@@ -131,7 +131,7 @@ public sealed class OperatorAuthEndpointsHttpTests
|
||||
[Fact]
|
||||
public async Task OperatorWithNonActiveStatus_LoginSucceeds_ButMeReturns401()
|
||||
{
|
||||
FakeOperatorAuthStore operatorStore = NewOperatorStore(active: false);
|
||||
TestOperatorAuthStore operatorStore = NewOperatorStore(active: false);
|
||||
|
||||
await OperatorAuthHttpHost.RunAsync(
|
||||
operatorStore,
|
||||
@@ -189,10 +189,10 @@ public sealed class OperatorAuthEndpointsHttpTests
|
||||
}
|
||||
|
||||
// Фейк-хранилище оператора с активным (или нет) оператором operator/operator.
|
||||
private static FakeOperatorAuthStore NewOperatorStore(bool active = true)
|
||||
private static TestOperatorAuthStore NewOperatorStore(bool active = true)
|
||||
{
|
||||
var passwordHasher = TestHashers.New();
|
||||
var store = new FakeOperatorAuthStore();
|
||||
var store = new TestOperatorAuthStore();
|
||||
store.AddOperator(new StoredOperatorDto(
|
||||
Guid.NewGuid(),
|
||||
OperatorLogin,
|
||||
@@ -202,10 +202,10 @@ public sealed class OperatorAuthEndpointsHttpTests
|
||||
}
|
||||
|
||||
// Фейк-хранилище пользователей с admin/admin в дефолтном тенанте.
|
||||
private static FakeAuthStore NewUserStore()
|
||||
private static TestAuthStore NewUserStore()
|
||||
{
|
||||
var passwordHasher = TestHashers.New();
|
||||
var store = new FakeAuthStore();
|
||||
var store = new TestAuthStore();
|
||||
store.AddUser(new StoredUserDto(
|
||||
Guid.NewGuid(),
|
||||
UserLogin,
|
||||
|
||||
@@ -33,17 +33,17 @@ internal static class OperatorAuthHttpHost
|
||||
"Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password;Timeout=3";
|
||||
|
||||
/// <summary>
|
||||
/// Прогоняет сценарий против поднятого хоста с фейк-хранилищами оператора и пользователей.
|
||||
/// Прогоняет сценарий против поднятого хоста с подставками хранилищ оператора и пользователей.
|
||||
/// </summary>
|
||||
/// <param name="operatorStore">Фейк-хранилище оператора (IOperatorAuthStore).</param>
|
||||
/// <param name="userStore">Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
|
||||
/// <param name="operatorStore">Подставка хранилища оператора (IOperatorAuthStore).</param>
|
||||
/// <param name="userStore">Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
|
||||
/// <param name="scenario">Сценарий: получает base-адрес и хранилища для проверок/досидирования.</param>
|
||||
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
|
||||
/// <param name="auditStore">Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
|
||||
public static async Task RunAsync(
|
||||
FakeOperatorAuthStore operatorStore,
|
||||
FakeAuthStore userStore,
|
||||
Func<string, FakeOperatorAuthStore, FakeAuthStore, Task> scenario,
|
||||
FakeAuditLogStore? auditStore = null) =>
|
||||
TestOperatorAuthStore operatorStore,
|
||||
TestAuthStore userStore,
|
||||
Func<string, TestOperatorAuthStore, TestAuthStore, Task> scenario,
|
||||
TestAuditLogStore? auditStore = null) =>
|
||||
await RunCoreAsync(
|
||||
operatorStore,
|
||||
userStore,
|
||||
@@ -56,16 +56,16 @@ internal static class OperatorAuthHttpHost
|
||||
/// <summary>
|
||||
/// Прогоняет сценарий
|
||||
/// </summary>
|
||||
/// <param name="operatorStore">Фейк-хранилище оператора (IOperatorAuthStore).</param>
|
||||
/// <param name="userStore">Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
|
||||
/// <param name="operatorStore">Подставка хранилища оператора (IOperatorAuthStore).</param>
|
||||
/// <param name="userStore">Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
|
||||
/// <param name="scenario">Сценарий: получает base-адрес и хранилища (без реестра тенантов).</param>
|
||||
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
|
||||
/// <param name="inviteStore">Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри.</param>
|
||||
/// <param name="auditStore">Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
|
||||
/// <param name="inviteStore">Подставка хранилища приглашений (IInviteStore); null — создаётся пустое внутри.</param>
|
||||
public static async Task RunAsync(
|
||||
FakeOperatorAuthStore operatorStore,
|
||||
FakeAuthStore userStore,
|
||||
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestInviteStore, FakeAuditLogStore, Task> scenario,
|
||||
FakeAuditLogStore? auditStore = null,
|
||||
TestOperatorAuthStore operatorStore,
|
||||
TestAuthStore userStore,
|
||||
Func<string, TestOperatorAuthStore, TestAuthStore, TestInviteStore, TestAuditLogStore, Task> scenario,
|
||||
TestAuditLogStore? auditStore = null,
|
||||
TestInviteStore? inviteStore = null) =>
|
||||
await RunCoreAsync(
|
||||
operatorStore,
|
||||
@@ -79,17 +79,17 @@ internal static class OperatorAuthHttpHost
|
||||
/// <summary>
|
||||
/// Прогоняет сценарий
|
||||
/// </summary>
|
||||
/// <param name="operatorStore">Фейк-хранилище оператора (IOperatorAuthStore).</param>
|
||||
/// <param name="userStore">Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
|
||||
/// <param name="operatorStore">Подставка хранилища оператора (IOperatorAuthStore).</param>
|
||||
/// <param name="userStore">Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
|
||||
/// <param name="scenario">Сценарий: получает base-адрес и все хранилища, кроме лимитов.</param>
|
||||
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
|
||||
/// <param name="inviteStore">Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри.</param>
|
||||
/// <param name="tenantStore">Фейк-хранилище реестра тенантов (ITenantRepository); null — создаётся пустое внутри.</param>
|
||||
/// <param name="auditStore">Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
|
||||
/// <param name="inviteStore">Подставка хранилища приглашений (IInviteStore); null — создаётся пустое внутри.</param>
|
||||
/// <param name="tenantStore">Подставка хранилища реестра тенантов (ITenantRepository); null — создаётся пустое внутри.</param>
|
||||
public static async Task RunAsync(
|
||||
FakeOperatorAuthStore operatorStore,
|
||||
FakeAuthStore userStore,
|
||||
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, Task> scenario,
|
||||
FakeAuditLogStore? auditStore = null,
|
||||
TestOperatorAuthStore operatorStore,
|
||||
TestAuthStore userStore,
|
||||
Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, Task> scenario,
|
||||
TestAuditLogStore? auditStore = null,
|
||||
TestInviteStore? inviteStore = null,
|
||||
TestTenantStore? tenantStore = null) =>
|
||||
await RunCoreAsync(
|
||||
@@ -104,22 +104,22 @@ internal static class OperatorAuthHttpHost
|
||||
/// <summary>
|
||||
/// Прогоняет сценарий
|
||||
/// </summary>
|
||||
/// <param name="operatorStore">Фейк-хранилище оператора (IOperatorAuthStore).</param>
|
||||
/// <param name="userStore">Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
|
||||
/// <param name="operatorStore">Подставка хранилища оператора (IOperatorAuthStore).</param>
|
||||
/// <param name="userStore">Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
|
||||
/// <param name="scenario">Сценарий: получает base-адрес и все хранилища, включая лимиты.</param>
|
||||
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
|
||||
/// <param name="inviteStore">Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри.</param>
|
||||
/// <param name="tenantStore">Фейк-хранилище реестра тенантов (ITenantRepository); null — создаётся пустое внутри.</param>
|
||||
/// <param name="limitStore">Фейк-хранилище лимитов (ITenantLimitStore); null — создаётся пустое внутри.</param>
|
||||
/// <param name="auditStore">Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
|
||||
/// <param name="inviteStore">Подставка хранилища приглашений (IInviteStore); null — создаётся пустое внутри.</param>
|
||||
/// <param name="tenantStore">Подставка хранилища реестра тенантов (ITenantRepository); null — создаётся пустое внутри.</param>
|
||||
/// <param name="limitStore">Подставка хранилища лимитов (ITenantLimitStore); null — создаётся пустое внутри.</param>
|
||||
/// <param name="rateLimitOptions">Опции rate limiting: null — выключенные по умолчанию (LoginAttemptGuard no-op, существующие сценарии не режутся); Enabled-опции — сценарии защиты входа.</param>
|
||||
public static async Task RunAsync(
|
||||
FakeOperatorAuthStore operatorStore,
|
||||
FakeAuthStore userStore,
|
||||
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario,
|
||||
FakeAuditLogStore? auditStore = null,
|
||||
TestOperatorAuthStore operatorStore,
|
||||
TestAuthStore userStore,
|
||||
Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, Task> scenario,
|
||||
TestAuditLogStore? auditStore = null,
|
||||
TestInviteStore? inviteStore = null,
|
||||
TestTenantStore? tenantStore = null,
|
||||
FakeTenantLimitStore? limitStore = null,
|
||||
TestTenantLimitStore? limitStore = null,
|
||||
RateLimitOptions? rateLimitOptions = null,
|
||||
TestTokenUsageEventStore? tokenUsageStore = null) =>
|
||||
await RunCoreAsync(
|
||||
@@ -136,16 +136,16 @@ internal static class OperatorAuthHttpHost
|
||||
/// <summary>
|
||||
/// Прогоняет сценарий с глобальными настройками
|
||||
/// </summary>
|
||||
/// <param name="operatorStore">Фейк-хранилище оператора (IOperatorAuthStore).</param>
|
||||
/// <param name="userStore">Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
|
||||
/// <param name="operatorStore">Подставка хранилища оператора (IOperatorAuthStore).</param>
|
||||
/// <param name="userStore">Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
|
||||
/// <param name="scenario">Сценарий: base-адрес, хранилища оператора/пользователей/глобальных настроек и аудита.</param>
|
||||
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
|
||||
/// <param name="globalSettingsStore">Фейк глобального KV-хранилища (IGlobalSettingsStore); null — создаётся пустое.</param>
|
||||
/// <param name="auditStore">Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
|
||||
/// <param name="globalSettingsStore">Подставка глобального KV-хранилища (IGlobalSettingsStore); null — создаётся пустое.</param>
|
||||
public static async Task RunWithGlobalSettingsAsync(
|
||||
FakeOperatorAuthStore operatorStore,
|
||||
FakeAuthStore userStore,
|
||||
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestGlobalSettingsStore, FakeAuditLogStore, Task> scenario,
|
||||
FakeAuditLogStore? auditStore = null,
|
||||
TestOperatorAuthStore operatorStore,
|
||||
TestAuthStore userStore,
|
||||
Func<string, TestOperatorAuthStore, TestAuthStore, TestGlobalSettingsStore, TestAuditLogStore, Task> scenario,
|
||||
TestAuditLogStore? auditStore = null,
|
||||
TestGlobalSettingsStore? globalSettingsStore = null) =>
|
||||
await RunCoreAsync(
|
||||
operatorStore,
|
||||
@@ -159,21 +159,21 @@ internal static class OperatorAuthHttpHost
|
||||
|
||||
// Поднимает in-process Kestrel-хост и прогоняет сценарий (см. публичные перегрузки).
|
||||
private static async Task RunCoreAsync(
|
||||
FakeOperatorAuthStore operatorStore,
|
||||
FakeAuthStore userStore,
|
||||
FakeAuditLogStore? auditStore,
|
||||
TestOperatorAuthStore operatorStore,
|
||||
TestAuthStore userStore,
|
||||
TestAuditLogStore? auditStore,
|
||||
TestInviteStore? inviteStore,
|
||||
TestTenantStore? tenantStore,
|
||||
FakeTenantLimitStore? limitStore,
|
||||
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, TestGlobalSettingsStore, Task> scenario,
|
||||
TestTenantLimitStore? limitStore,
|
||||
Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, TestGlobalSettingsStore, Task> scenario,
|
||||
RateLimitOptions? rateLimitOptions = null,
|
||||
TestTokenUsageEventStore? tokenUsageStore = null,
|
||||
TestGlobalSettingsStore? globalSettingsStore = null)
|
||||
{
|
||||
FakeAuditLogStore effectiveAuditStore = auditStore ?? new FakeAuditLogStore();
|
||||
TestAuditLogStore effectiveAuditStore = auditStore ?? new TestAuditLogStore();
|
||||
TestInviteStore effectiveInviteStore = inviteStore ?? new TestInviteStore();
|
||||
TestTenantStore effectiveTenantStore = tenantStore ?? new TestTenantStore();
|
||||
FakeTenantLimitStore effectiveLimitStore = limitStore ?? new FakeTenantLimitStore();
|
||||
TestTenantLimitStore effectiveLimitStore = limitStore ?? new TestTenantLimitStore();
|
||||
TestTokenUsageEventStore effectiveTokenUsageStore = tokenUsageStore ?? new TestTokenUsageEventStore();
|
||||
TestGlobalSettingsStore effectiveGlobalSettingsStore = globalSettingsStore ?? new TestGlobalSettingsStore();
|
||||
RateLimitOptions effectiveRateLimitOptions = rateLimitOptions ?? new RateLimitOptions();
|
||||
@@ -186,12 +186,12 @@ internal static class OperatorAuthHttpHost
|
||||
// последняя регистрация (зеркало AddTenantsModule в тестах DiscoveryWorkerSchedulerTests).
|
||||
builder.Services.AddSingleton(TestHashers.New());
|
||||
builder.Services.AddSingleton<ITenantContext, TenantContext>();
|
||||
builder.Services.AddSingleton<IAuthStore>(userStore);
|
||||
builder.Services.AddSingleton<IOperatorAuthStore>(operatorStore);
|
||||
builder.Services.AddSingleton<IAuditLogStore>(effectiveAuditStore);
|
||||
builder.Services.AddSingleton<IAuthStore>(userStore.Store);
|
||||
builder.Services.AddSingleton<IOperatorAuthStore>(operatorStore.Store);
|
||||
builder.Services.AddSingleton<IAuditLogStore>(effectiveAuditStore.Store);
|
||||
builder.Services.AddSingleton<IInviteStore>(effectiveInviteStore.Store);
|
||||
builder.Services.AddSingleton<ITenantRepository>(effectiveTenantStore.Repository);
|
||||
builder.Services.AddSingleton<ITenantLimitStore>(effectiveLimitStore);
|
||||
builder.Services.AddSingleton<ITenantLimitStore>(effectiveLimitStore.Store);
|
||||
builder.Services.AddSingleton<IGlobalSettingsStore>(effectiveGlobalSettingsStore.Store);
|
||||
builder.Services.AddSingleton<ISecretCipher>(TestCiphers.New());
|
||||
// Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys).
|
||||
|
||||
@@ -92,14 +92,14 @@ public sealed class OperatorHealthEndpointsHttpTests
|
||||
}
|
||||
|
||||
// Прогоняет сценарий на хосте с активным оператором operator/operator (реестр/лимиты пустые).
|
||||
private static Task RunAsync(Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario) =>
|
||||
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario);
|
||||
private static Task RunAsync(Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, Task> scenario) =>
|
||||
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new TestAuthStore(), scenario);
|
||||
|
||||
// Фейк-хранилище оператора с активным оператором operator/operator.
|
||||
private static FakeOperatorAuthStore NewOperatorStore()
|
||||
private static TestOperatorAuthStore NewOperatorStore()
|
||||
{
|
||||
var passwordHasher = TestHashers.New();
|
||||
var store = new FakeOperatorAuthStore();
|
||||
var store = new TestOperatorAuthStore();
|
||||
store.AddOperator(new StoredOperatorDto(
|
||||
Guid.NewGuid(),
|
||||
OperatorLogin,
|
||||
|
||||
@@ -70,7 +70,7 @@ public sealed class OperatorInvitesEndpointsHttpTests
|
||||
[Fact]
|
||||
public async Task CreateThenListThenRevoke_FullOperatorFlow_WorksAndWritesAudit()
|
||||
{
|
||||
var auditStore = new FakeAuditLogStore();
|
||||
var auditStore = new TestAuditLogStore();
|
||||
|
||||
await OperatorAuthHttpHost.RunAsync(
|
||||
NewOperatorStore(),
|
||||
@@ -262,10 +262,10 @@ public sealed class OperatorInvitesEndpointsHttpTests
|
||||
}
|
||||
|
||||
// Фейк-хранилище оператора с активным оператором operator/operator.
|
||||
private static FakeOperatorAuthStore NewOperatorStore()
|
||||
private static TestOperatorAuthStore NewOperatorStore()
|
||||
{
|
||||
var passwordHasher = TestHashers.New();
|
||||
var store = new FakeOperatorAuthStore();
|
||||
var store = new TestOperatorAuthStore();
|
||||
store.AddOperator(new StoredOperatorDto(
|
||||
Guid.NewGuid(),
|
||||
OperatorLogin,
|
||||
@@ -275,7 +275,7 @@ public sealed class OperatorInvitesEndpointsHttpTests
|
||||
}
|
||||
|
||||
// Пустое фейк-хранилище пользователей (тенантные ручки в этих сценариях не используются).
|
||||
private static FakeAuthStore NewUserStore() => new();
|
||||
private static TestAuthStore NewUserStore() => new();
|
||||
|
||||
// HTTP-клиент с собственным CookieContainer (изоляция сценариев кук).
|
||||
private static HttpClient CreateClient(string baseAddress) =>
|
||||
|
||||
@@ -120,7 +120,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
|
||||
[Fact]
|
||||
public async Task PatchLimit_ResetsFlags_UpdatesBudgetAndWritesAudit()
|
||||
{
|
||||
var auditStore = new FakeAuditLogStore();
|
||||
var auditStore = new TestAuditLogStore();
|
||||
const long newBudget = 20_000_000;
|
||||
|
||||
await RunAsync(
|
||||
@@ -165,7 +165,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
|
||||
[Fact]
|
||||
public async Task PatchLimit_OnlyPeriod_KeepsBudgetAndWritesAudit()
|
||||
{
|
||||
var auditStore = new FakeAuditLogStore();
|
||||
var auditStore = new TestAuditLogStore();
|
||||
|
||||
await RunAsync(
|
||||
async (baseAddress, operatorStore, _, _, _, _, _) =>
|
||||
@@ -195,7 +195,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
|
||||
[Fact]
|
||||
public async Task PatchLimit_WithSameValues_IsIdempotent_WithoutNewAudit()
|
||||
{
|
||||
var auditStore = new FakeAuditLogStore();
|
||||
var auditStore = new TestAuditLogStore();
|
||||
const long budget = 25_000_000;
|
||||
|
||||
await RunAsync(
|
||||
@@ -284,19 +284,19 @@ public sealed class OperatorLimitsEndpointsHttpTests
|
||||
$"{baseAddress}/api/operator/tenants/{tenantId}/limit";
|
||||
|
||||
// Прогоняет сценарий на хосте с двумя активными тенантами и предзаполненными строками лимитов.
|
||||
private static Task RunAsync(Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario, FakeAuditLogStore? auditStore = null)
|
||||
private static Task RunAsync(Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, Task> scenario, TestAuditLogStore? auditStore = null)
|
||||
{
|
||||
// Первый тенант: расход 80% месячного бюджета (8 000 000 / 10 000 000) + флаг Warned80;
|
||||
// второй: небольшой бюджет без расхода. Дата начала периода — текущая (reset не срабатывает).
|
||||
var tenantStore = new TestTenantStore(
|
||||
new TenantRecordDto(FirstTenantId, FirstTenantName, TenantStatuses.Active, DateTimeOffset.UtcNow),
|
||||
new TenantRecordDto(SecondTenantId, SecondTenantName, TenantStatuses.Active, DateTimeOffset.UtcNow));
|
||||
var limitStore = new FakeTenantLimitStore();
|
||||
var limitStore = new TestTenantLimitStore();
|
||||
limitStore.Preload(FirstTenantId, DefaultBudgetTokens, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 8_000_000, warned80: true);
|
||||
limitStore.Preload(SecondTenantId, 100_000, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 0);
|
||||
return OperatorAuthHttpHost.RunAsync(
|
||||
NewOperatorStore(),
|
||||
new FakeAuthStore(),
|
||||
new TestAuthStore(),
|
||||
scenario,
|
||||
auditStore,
|
||||
tenantStore: tenantStore,
|
||||
@@ -326,10 +326,10 @@ public sealed class OperatorLimitsEndpointsHttpTests
|
||||
}
|
||||
|
||||
// Фейк-хранилище оператора с активным оператором operator/operator.
|
||||
private static FakeOperatorAuthStore NewOperatorStore()
|
||||
private static TestOperatorAuthStore NewOperatorStore()
|
||||
{
|
||||
var passwordHasher = TestHashers.New();
|
||||
var store = new FakeOperatorAuthStore();
|
||||
var store = new TestOperatorAuthStore();
|
||||
store.AddOperator(new StoredOperatorDto(
|
||||
Guid.NewGuid(),
|
||||
OperatorLogin,
|
||||
|
||||
@@ -72,14 +72,14 @@ public sealed class OperatorMaintenanceEndpointsHttpTests
|
||||
}
|
||||
|
||||
// Прогоняет сценарий на хосте с активным оператором operator/operator и заданным реестром тенантов.
|
||||
private static Task RunAsync(TestTenantStore tenantStore, Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, Task> scenario) =>
|
||||
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario, tenantStore: tenantStore);
|
||||
private static Task RunAsync(TestTenantStore tenantStore, Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, Task> scenario) =>
|
||||
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new TestAuthStore(), scenario, tenantStore: tenantStore);
|
||||
|
||||
// Фейк-хранилище оператора с активным оператором operator/operator.
|
||||
private static FakeOperatorAuthStore NewOperatorStore()
|
||||
private static TestOperatorAuthStore NewOperatorStore()
|
||||
{
|
||||
var passwordHasher = TestHashers.New();
|
||||
var store = new FakeOperatorAuthStore();
|
||||
var store = new TestOperatorAuthStore();
|
||||
store.AddOperator(new StoredOperatorDto(
|
||||
Guid.NewGuid(),
|
||||
OperatorLogin,
|
||||
|
||||
@@ -61,7 +61,7 @@ public sealed class OperatorSettingsEndpointsHttpTests
|
||||
[Fact]
|
||||
public async Task PutTelegramKeys_SavesEncryptedKeysMasksResponseAndWritesAudit()
|
||||
{
|
||||
var auditStore = new FakeAuditLogStore();
|
||||
var auditStore = new TestAuditLogStore();
|
||||
|
||||
await RunAsync(
|
||||
async (baseAddress, operatorStore, _, globalSettings, audit) =>
|
||||
@@ -257,10 +257,10 @@ public sealed class OperatorSettingsEndpointsHttpTests
|
||||
$"{baseAddress}/api/operator/settings/telegram-keys";
|
||||
|
||||
// Прогоняет сценарий на хосте с фейком глобального хранилища и аудита.
|
||||
private static Task RunAsync(Func<string, FakeOperatorAuthStore, FakeAuthStore, TestGlobalSettingsStore, FakeAuditLogStore, Task> scenario, FakeAuditLogStore? auditStore = null) =>
|
||||
private static Task RunAsync(Func<string, TestOperatorAuthStore, TestAuthStore, TestGlobalSettingsStore, TestAuditLogStore, Task> scenario, TestAuditLogStore? auditStore = null) =>
|
||||
OperatorAuthHttpHost.RunWithGlobalSettingsAsync(
|
||||
NewOperatorStore(),
|
||||
new FakeAuthStore(),
|
||||
new TestAuthStore(),
|
||||
scenario,
|
||||
auditStore);
|
||||
|
||||
@@ -273,10 +273,10 @@ public sealed class OperatorSettingsEndpointsHttpTests
|
||||
}
|
||||
|
||||
// Фейк-хранилище оператора с активным оператором operator/operator.
|
||||
private static FakeOperatorAuthStore NewOperatorStore()
|
||||
private static TestOperatorAuthStore NewOperatorStore()
|
||||
{
|
||||
var passwordHasher = TestHashers.New();
|
||||
var store = new FakeOperatorAuthStore();
|
||||
var store = new TestOperatorAuthStore();
|
||||
store.AddOperator(new StoredOperatorDto(
|
||||
Guid.NewGuid(),
|
||||
OperatorLogin,
|
||||
|
||||
@@ -97,7 +97,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
|
||||
[Fact]
|
||||
public async Task Create_ReturnsCreatedTenant_AndWritesTenantCreatedAudit()
|
||||
{
|
||||
var auditStore = new FakeAuditLogStore();
|
||||
var auditStore = new TestAuditLogStore();
|
||||
|
||||
await RunAsync(
|
||||
async (baseAddress, operatorStore, _, tenantStore, _) =>
|
||||
@@ -138,7 +138,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
|
||||
[Fact]
|
||||
public async Task Create_WithOwnerEmail_ReturnsOneTimePasswordAndCreatesOwner()
|
||||
{
|
||||
var auditStore = new FakeAuditLogStore();
|
||||
var auditStore = new TestAuditLogStore();
|
||||
const string ownerEmail = "owner@created.com";
|
||||
|
||||
await RunAsync(
|
||||
@@ -237,7 +237,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
|
||||
[Fact]
|
||||
public async Task Suspend_BlocksTenantLogin_ThenUnsuspend_RestoresLogin()
|
||||
{
|
||||
var auditStore = new FakeAuditLogStore();
|
||||
var auditStore = new TestAuditLogStore();
|
||||
|
||||
await RunAsync(
|
||||
async (baseAddress, operatorStore, userStore, tenantStore, _) =>
|
||||
@@ -300,7 +300,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
|
||||
[Fact]
|
||||
public async Task Suspend_AlreadySuspendedTenant_IsOkWithoutDuplicateAudit()
|
||||
{
|
||||
var auditStore = new FakeAuditLogStore();
|
||||
var auditStore = new TestAuditLogStore();
|
||||
var tenantStore = new TestTenantStore(
|
||||
new TenantRecordDto(TenantId, TenantName, TenantStatuses.Suspended, DateTimeOffset.UtcNow));
|
||||
|
||||
@@ -342,7 +342,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
|
||||
[Fact]
|
||||
public async Task Impersonate_ReturnsSessionToken_WorksAsDealSession_AndLogoutWritesStoppedAudit()
|
||||
{
|
||||
var auditStore = new FakeAuditLogStore();
|
||||
var auditStore = new TestAuditLogStore();
|
||||
|
||||
await RunAsync(
|
||||
async (baseAddress, operatorStore, userStore, _, _) =>
|
||||
@@ -486,8 +486,8 @@ public sealed class OperatorTenantsEndpointsHttpTests
|
||||
|
||||
// Прогоняет сценарий на хосте с одним активным тенантом и одним пользователем в нём.
|
||||
private static Task RunAsync(
|
||||
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, FakeAuditLogStore, Task> scenario,
|
||||
FakeAuditLogStore? auditStore = null,
|
||||
Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestAuditLogStore, Task> scenario,
|
||||
TestAuditLogStore? auditStore = null,
|
||||
TestTenantStore? tenantStore = null)
|
||||
{
|
||||
// По умолчанию в реестре — активный целевой тенант (пользователь в нём — NewUserStore).
|
||||
@@ -503,8 +503,8 @@ public sealed class OperatorTenantsEndpointsHttpTests
|
||||
|
||||
// Проверяет запись tenant_status_changed с заданным статусом и актором-оператором.
|
||||
private static void AssertStatusChangedAudit(
|
||||
FakeAuditLogStore auditStore,
|
||||
FakeOperatorAuthStore operatorStore,
|
||||
TestAuditLogStore auditStore,
|
||||
TestOperatorAuthStore operatorStore,
|
||||
string status)
|
||||
{
|
||||
// В ленте может быть несколько tenant_status_changed этого тенанта (suspend → unsuspend) — ищем по статусу.
|
||||
@@ -569,10 +569,10 @@ public sealed class OperatorTenantsEndpointsHttpTests
|
||||
}
|
||||
|
||||
// Фейк-хранилище оператора с активным оператором operator/operator.
|
||||
private static FakeOperatorAuthStore NewOperatorStore()
|
||||
private static TestOperatorAuthStore NewOperatorStore()
|
||||
{
|
||||
var passwordHasher = TestHashers.New();
|
||||
var store = new FakeOperatorAuthStore();
|
||||
var store = new TestOperatorAuthStore();
|
||||
store.AddOperator(new StoredOperatorDto(
|
||||
Guid.NewGuid(),
|
||||
OperatorLogin,
|
||||
@@ -582,10 +582,10 @@ public sealed class OperatorTenantsEndpointsHttpTests
|
||||
}
|
||||
|
||||
// Фейк-хранилище пользователей с одним пользователем в целевом тенанте.
|
||||
private static FakeAuthStore NewUserStore()
|
||||
private static TestAuthStore NewUserStore()
|
||||
{
|
||||
var passwordHasher = TestHashers.New();
|
||||
var store = new FakeAuthStore();
|
||||
var store = new TestAuthStore();
|
||||
store.AddUser(new StoredUserDto(
|
||||
Guid.NewGuid(),
|
||||
TenantUserLogin,
|
||||
|
||||
@@ -3,6 +3,7 @@ using Deal.Api.Events;
|
||||
using Deal.Api.Services;
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using Deal.Infrastructure.Data;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
@@ -17,7 +18,6 @@ using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Deal.Tests.Unit.Contracts;
|
||||
using Deal.Tests.Unit.Modules.Cards;
|
||||
using Deal.Tests.Unit.Modules.Kanban;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
using Deal.Tests.Unit.Modules.Tenants;
|
||||
@@ -40,11 +40,11 @@ public sealed class PipelineWorkerSchedulerTests
|
||||
// Контекст теста: планировщик на общих фейках + каналы подписок тенантов.
|
||||
private sealed record Context(
|
||||
PipelineWorkerScheduler Scheduler,
|
||||
FakePipelineStore PipelineA,
|
||||
FakeKanjStore KanjA,
|
||||
TestPipelineStore PipelineA,
|
||||
TestKanjStore KanjA,
|
||||
SseSubscription SubscriptionA,
|
||||
FakePipelineStore PipelineB,
|
||||
FakeKanjStore KanjB,
|
||||
TestPipelineStore PipelineB,
|
||||
TestKanjStore KanjB,
|
||||
SseSubscription SubscriptionB,
|
||||
PipelinePumpGate PumpGate,
|
||||
TenantContext TenantContext,
|
||||
@@ -150,12 +150,12 @@ public sealed class PipelineWorkerSchedulerTests
|
||||
{
|
||||
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
|
||||
var tenantContext = new TenantContext();
|
||||
FakePipelineStore pipelineA = withThrowingQueueReadA ? new ThrowingQueueReadPipelineStore() : new FakePipelineStore();
|
||||
var pipelineB = new FakePipelineStore();
|
||||
var kanjA = new FakeKanjStore();
|
||||
var kanjB = new FakeKanjStore();
|
||||
var settingsA = new FakeSettingsStore();
|
||||
var settingsB = new FakeSettingsStore();
|
||||
var pipelineA = new TestPipelineStore(throwOnList: withThrowingQueueReadA);
|
||||
var pipelineB = new TestPipelineStore();
|
||||
var kanjA = new TestKanjStore();
|
||||
var kanjB = new TestKanjStore();
|
||||
var settingsA = new TestSettingsStore();
|
||||
var settingsB = new TestSettingsStore();
|
||||
var pumpGate = new PipelinePumpGate();
|
||||
var aiClassifier = new TestAiClassifier
|
||||
{
|
||||
@@ -167,13 +167,13 @@ public sealed class PipelineWorkerSchedulerTests
|
||||
services.AddSingleton<SseBroker>();
|
||||
services.AddSingleton<ITenantRepository>(tenants.Repository);
|
||||
services.AddSingleton(pumpGate);
|
||||
services.AddSingleton<IMlClient>(new FakeMlClient());
|
||||
services.AddSingleton<IMlClient>(new TestMlClient().Client);
|
||||
services.AddSingleton<IAiClassifier>(aiClassifier.Classifier);
|
||||
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
|
||||
// (эталон StorageTickSchedulerTests/ConnectionStringProvider.ForTenant).
|
||||
services.AddScoped<ICardStore>(provider => TenantOf(provider) == TenantA ? kanjA : kanjB);
|
||||
services.AddScoped<ISettingsStore>(provider => TenantOf(provider) == TenantA ? settingsA : settingsB);
|
||||
services.AddScoped<IPipelineStore>(provider => TenantOf(provider) == TenantA ? pipelineA : pipelineB);
|
||||
services.AddScoped<ICardStore>(provider => TenantOf(provider) == TenantA ? kanjA.Store : kanjB.Store);
|
||||
services.AddScoped<ISettingsStore>(provider => TenantOf(provider) == TenantA ? settingsA.Store : settingsB.Store);
|
||||
services.AddScoped<IPipelineStore>(provider => TenantOf(provider) == TenantA ? pipelineA.Store : pipelineB.Store);
|
||||
// Реальные сервисы модуля Pipeline — как AddPipelineModule в Program.cs: цикл резолвит их в tenant-scope.
|
||||
services.AddScoped<IncomingRules>();
|
||||
services.AddScoped<LocalFieldsParser>();
|
||||
@@ -266,18 +266,7 @@ public sealed class PipelineWorkerSchedulerTests
|
||||
return cardIds;
|
||||
}
|
||||
|
||||
// Хранилище со сбоем чтения очереди: ListAsync бросает (сценарий «БД/схема недоступны» на pump).
|
||||
private sealed class ThrowingQueueReadPipelineStore : FakePipelineStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override Task<IReadOnlyList<QueueItemDto>> ListAsync(
|
||||
string? status,
|
||||
int limit,
|
||||
CancellationToken ct)
|
||||
{
|
||||
throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync).");
|
||||
}
|
||||
}
|
||||
// Хранилище со сбоем чтения очереди задаётся флагом throwOnList в TestPipelineStore.
|
||||
|
||||
// Логгер-коллектор: копит сообщения планировщика (диагностика в тестах сбоя pump).
|
||||
private sealed class ListLogger : ILogger<PipelineWorkerScheduler>
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Parse;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Tests.Unit.Contracts;
|
||||
using Deal.Tests.Unit.Modules.Cards;
|
||||
using Deal.Tests.Unit.Modules.Kanban;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
|
||||
@@ -24,29 +24,29 @@ public sealed class PipelineWorkerServiceTests
|
||||
// Контекст теста: воркер поверх in-memory фейков хранилищ/портов.
|
||||
private sealed record Context(
|
||||
PipelineWorkerService Worker,
|
||||
FakePipelineStore PipelineStore,
|
||||
FakeKanjStore KanjStore,
|
||||
FakeSettingsStore Settings,
|
||||
FakeMlClient MlClient,
|
||||
TestPipelineStore PipelineStore,
|
||||
TestKanjStore KanjStore,
|
||||
TestSettingsStore Settings,
|
||||
TestMlClient MlClient,
|
||||
TestAiClassifier AiClassifier);
|
||||
|
||||
// Собирает контекст: дефолты настроек/досок, ML «не готов» (как LocalMlClient), ИИ-фильтр пропускает.
|
||||
// pipelineStore: Хранилище очереди (по умолчанию — обычный фейк; сценарий гонки claim'а — RacingClaimPipelineStore).
|
||||
// Возвращает: Воркер и фейки (сценарий до-настраивает строки очереди/ответы портов).
|
||||
private static Context CreateContext(FakePipelineStore? pipelineStore = null)
|
||||
private static Context CreateContext(TestPipelineStore? pipelineStore = null)
|
||||
{
|
||||
var settings = new FakeSettingsStore();
|
||||
var store = pipelineStore ?? new FakePipelineStore();
|
||||
var kanjStore = new FakeKanjStore();
|
||||
var mlClient = new FakeMlClient { Predict = NotReadyPrediction() };
|
||||
var settings = new TestSettingsStore();
|
||||
var store = pipelineStore ?? new TestPipelineStore();
|
||||
var kanjStore = new TestKanjStore();
|
||||
var mlClient = new TestMlClient { Predict = NotReadyPrediction() };
|
||||
var aiClassifier = new TestAiClassifier();
|
||||
var rules = new IncomingRules(settings);
|
||||
var fieldsParser = new LocalFieldsParser(settings);
|
||||
var processing = new PipelineProcessingService(store, mlClient, new PipelineIngestService(store));
|
||||
var composer = new CardComposer(kanjStore, settings);
|
||||
var writer = new PipelineCardWriter(kanjStore, store, composer);
|
||||
var rules = new IncomingRules(settings.Store);
|
||||
var fieldsParser = new LocalFieldsParser(settings.Store);
|
||||
var processing = new PipelineProcessingService(store.Store, mlClient.Client, new PipelineIngestService(store.Store));
|
||||
var composer = new CardComposer(kanjStore.Store, settings.Store);
|
||||
var writer = new PipelineCardWriter(kanjStore.Store, store.Store, composer);
|
||||
var worker = new PipelineWorkerService(
|
||||
store, settings, rules, kanjStore, mlClient, aiClassifier.Classifier, processing, writer, fieldsParser);
|
||||
store.Store, settings.Store, rules, kanjStore.Store, mlClient.Client, aiClassifier.Classifier, processing, writer, fieldsParser);
|
||||
return new Context(worker, store, kanjStore, settings, mlClient, aiClassifier);
|
||||
}
|
||||
|
||||
@@ -54,38 +54,7 @@ public sealed class PipelineWorkerServiceTests
|
||||
// видит заявку, но ClaimAsync (INSERT … ON CONFLICT DO NOTHING в адаптере) возвращает false — хэш успел
|
||||
// заявить другой проход. DeleteClaimAsync записывается — проверяем, что чужая заявка НЕ снимается.
|
||||
// claimResult: Результат ClaimAsync (по умолчанию false — «победил другой проход»).
|
||||
private sealed class RacingClaimPipelineStore(bool claimResult = false) : FakePipelineStore
|
||||
{
|
||||
/// <summary>
|
||||
/// Сколько раз вызван ClaimAsync
|
||||
/// </summary>
|
||||
public int ClaimCalls { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Сколько раз вызван DeleteClaimAsync
|
||||
/// </summary>
|
||||
public int DeleteClaimCalls { get; private set; }
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<bool> ExistsAsync(string hash, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(false); // параллельный проход ещё не вставил строку на момент проверки
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task<bool> ClaimAsync(string hash, CancellationToken ct)
|
||||
{
|
||||
ClaimCalls++;
|
||||
return Task.FromResult(claimResult); // ON CONFLICT DO NOTHING: вставил другой проход → false
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public override Task DeleteClaimAsync(string hash, CancellationToken ct)
|
||||
{
|
||||
DeleteClaimCalls++;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
}
|
||||
|
||||
// Сериализует значение настройки в JSON-строку строки settings (как пишет SettingsStore).
|
||||
// value: Значение (число/булево/строка/список).
|
||||
@@ -284,13 +253,13 @@ public sealed class PipelineWorkerServiceTests
|
||||
[Fact]
|
||||
public async Task Pump_ClaimLostToParallelPass_RejectsDuplicateAndDoesNotCreateCard()
|
||||
{
|
||||
Context ctx = CreateContext(new RacingClaimPipelineStore());
|
||||
Context ctx = CreateContext(new TestPipelineStore(claimRace: true));
|
||||
const string text = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц";
|
||||
ctx.PipelineStore.SeedQueue(QueueRow("p_1", text));
|
||||
|
||||
PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default);
|
||||
|
||||
RacingClaimPipelineStore store = Assert.IsType<RacingClaimPipelineStore>(ctx.PipelineStore);
|
||||
TestPipelineStore store = ctx.PipelineStore;
|
||||
Assert.Equal(1, store.ClaimCalls); // конфликт claim'а обработан, а не пропущен молча
|
||||
RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected);
|
||||
Assert.Equal("dup", rejected.Stage);
|
||||
@@ -377,7 +346,7 @@ public sealed class PipelineWorkerServiceTests
|
||||
Assert.Empty(ctx.KanjStore.CardDtos);
|
||||
Assert.Equal(1, result.NoBudget);
|
||||
Assert.Equal(0, result.AiStored); // отсев фильтром не считается решением ИИ (python L1143–1147)
|
||||
Assert.False(await ctx.PipelineStore.ExistsAsync(DedupHasher.Hash(text), default));
|
||||
Assert.False(await ctx.PipelineStore.Store.ExistsAsync(DedupHasher.Hash(text), default));
|
||||
Assert.Null(ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions));
|
||||
}
|
||||
|
||||
@@ -615,7 +584,7 @@ public sealed class PipelineWorkerServiceTests
|
||||
|
||||
QueueItemDto row = Assert.Single(ctx.PipelineStore.Queue);
|
||||
Assert.Equal(PipelineQueueStatuses.Filtered, row.Status); // прошла «new»-проход, ИИ-шаг не завершился
|
||||
Assert.True(await ctx.PipelineStore.ExistsAsync(DedupHasher.Hash(text), default)); // claim на месте
|
||||
Assert.True(await ctx.PipelineStore.Store.ExistsAsync(DedupHasher.Hash(text), default)); // claim на месте
|
||||
Assert.Empty(ctx.PipelineStore.Rejected);
|
||||
Assert.Empty(ctx.KanjStore.CardDtos);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using Deal.Modules.Settings.Application.Services;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
using NSubstitute;
|
||||
@@ -14,7 +15,7 @@ public sealed class RatesServiceTests
|
||||
{
|
||||
private const long FixedTimestampMs = 1_752_000_000_000;
|
||||
|
||||
private readonly FakeSettingsStore _store = new();
|
||||
private readonly TestSettingsStore _store = new();
|
||||
|
||||
// ─── GetAsync: кэш и дефолт ─────────────────────────────────────────────
|
||||
|
||||
@@ -418,7 +419,7 @@ public sealed class RatesServiceTests
|
||||
|
||||
private RatesService CreateService(IRatesSource source, IReadOnlyList<IRatesChangedListener> listeners)
|
||||
{
|
||||
return new RatesService(_store, source, listeners);
|
||||
return new RatesService(_store.Store, source, listeners);
|
||||
}
|
||||
|
||||
// JSON значения ratesCache с одним курсом USD и заданным временем/источником.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using Deal.Modules.Settings.Application.Services;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
using NSubstitute;
|
||||
@@ -12,13 +13,13 @@ namespace Deal.Tests.Unit.Support;
|
||||
/// </summary>
|
||||
public sealed class SettingsServiceTests
|
||||
{
|
||||
private readonly FakeSettingsStore _store = new();
|
||||
private readonly TestSettingsStore _store = new();
|
||||
private readonly ISecretCipher _cipher = TestCiphers.New();
|
||||
private readonly SettingsService _service;
|
||||
|
||||
public SettingsServiceTests()
|
||||
{
|
||||
_service = new SettingsService(_store, _cipher, Array.Empty<IRatesChangedListener>());
|
||||
_service = new SettingsService(_store.Store, _cipher, Array.Empty<IRatesChangedListener>());
|
||||
}
|
||||
|
||||
// ─── GET: снимок ─────────────────────────────────────────────────────────
|
||||
@@ -592,7 +593,7 @@ public sealed class SettingsServiceTests
|
||||
public async Task ApplyPatchAsync_TargetCurrencyInBody_NotifiesListenersAfterSave()
|
||||
{
|
||||
var listener = Substitute.For<IRatesChangedListener>();
|
||||
SettingsService local = new(_store, _cipher, new[] { listener });
|
||||
SettingsService local = new(_store.Store, _cipher, new[] { listener });
|
||||
|
||||
PublicSettingsDto snapshot = await local.ApplyPatchAsync(JsonBody(new { targetCurrency = "usd" }), CancellationToken.None);
|
||||
|
||||
@@ -605,7 +606,7 @@ public sealed class SettingsServiceTests
|
||||
public async Task ApplyPatchAsync_ConversionOnInBody_NotifiesListeners()
|
||||
{
|
||||
var listener = Substitute.For<IRatesChangedListener>();
|
||||
SettingsService local = new(_store, _cipher, new[] { listener });
|
||||
SettingsService local = new(_store.Store, _cipher, new[] { listener });
|
||||
|
||||
await local.ApplyPatchAsync(JsonBody(new { conversionOn = false }), CancellationToken.None);
|
||||
|
||||
@@ -617,7 +618,7 @@ public sealed class SettingsServiceTests
|
||||
public async Task ApplyPatchAsync_UnrelatedKeys_DoesNotNotifyListeners()
|
||||
{
|
||||
var listener = Substitute.For<IRatesChangedListener>();
|
||||
SettingsService local = new(_store, _cipher, new[] { listener });
|
||||
SettingsService local = new(_store.Store, _cipher, new[] { listener });
|
||||
|
||||
await local.ApplyPatchAsync(JsonBody(new { minLen = 100, rateSource = "mock" }), CancellationToken.None);
|
||||
|
||||
@@ -628,7 +629,7 @@ public sealed class SettingsServiceTests
|
||||
public async Task ApplyPatchAsync_TargetCurrencyJsonNull_DoesNotNotifyListeners()
|
||||
{
|
||||
var listener = Substitute.For<IRatesChangedListener>();
|
||||
SettingsService local = new(_store, _cipher, new[] { listener });
|
||||
SettingsService local = new(_store.Store, _cipher, new[] { listener });
|
||||
|
||||
await local.ApplyPatchAsync(JsonBody(new Dictionary<string, object?> { ["targetCurrency"] = null }), CancellationToken.None);
|
||||
|
||||
|
||||
@@ -46,12 +46,12 @@ public sealed class SourceIngressGrpcServiceTests
|
||||
[Fact]
|
||||
public async Task PushSource_ValidTenant_EnqueuesQueueRowAndAccepts()
|
||||
{
|
||||
var store = new FakePipelineStore();
|
||||
var store = new TestPipelineStore();
|
||||
var registry = TestRegistry.With(Tenant(TenantA));
|
||||
|
||||
await RunAsync(
|
||||
registry,
|
||||
services => services.AddScoped<IPipelineStore>(_ => store),
|
||||
services => services.AddScoped<IPipelineStore>(_ => store.Store),
|
||||
async channel =>
|
||||
{
|
||||
PushSourceReply reply = await PushAsync(
|
||||
@@ -80,12 +80,12 @@ public sealed class SourceIngressGrpcServiceTests
|
||||
[Fact]
|
||||
public async Task PushSource_SameSourceKeyTwice_SecondIsDuplicateAndQueueNotGrown()
|
||||
{
|
||||
var store = new FakePipelineStore();
|
||||
var store = new TestPipelineStore();
|
||||
var registry = TestRegistry.With(Tenant(TenantA));
|
||||
|
||||
await RunAsync(
|
||||
registry,
|
||||
services => services.AddScoped<IPipelineStore>(_ => store),
|
||||
services => services.AddScoped<IPipelineStore>(_ => store.Store),
|
||||
async channel =>
|
||||
{
|
||||
PushSourceReply first = await PushAsync(channel, Request("Сообщение канала", externalId: "42"), TenantA, ValidToken);
|
||||
@@ -104,12 +104,12 @@ public sealed class SourceIngressGrpcServiceTests
|
||||
[Fact]
|
||||
public async Task PushSource_EmptyText_NotAcceptedAndQueueNotGrown()
|
||||
{
|
||||
var store = new FakePipelineStore();
|
||||
var store = new TestPipelineStore();
|
||||
var registry = TestRegistry.With(Tenant(TenantA));
|
||||
|
||||
await RunAsync(
|
||||
registry,
|
||||
services => services.AddScoped<IPipelineStore>(_ => store),
|
||||
services => services.AddScoped<IPipelineStore>(_ => store.Store),
|
||||
async channel =>
|
||||
{
|
||||
PushSourceReply reply = await PushAsync(channel, Request(" ", externalId: "9"), TenantA, ValidToken);
|
||||
@@ -126,12 +126,12 @@ public sealed class SourceIngressGrpcServiceTests
|
||||
[Fact]
|
||||
public async Task PushSource_UnknownTenant_NotAcceptedWithoutRpcError()
|
||||
{
|
||||
var store = new FakePipelineStore();
|
||||
var store = new TestPipelineStore();
|
||||
var registry = TestRegistry.With(Tenant(TenantA));
|
||||
|
||||
await RunAsync(
|
||||
registry,
|
||||
services => services.AddScoped<IPipelineStore>(_ => store),
|
||||
services => services.AddScoped<IPipelineStore>(_ => store.Store),
|
||||
async channel =>
|
||||
{
|
||||
PushSourceReply reply = await PushAsync(channel, Request("Сообщение чужого тенанта", externalId: "5"), Guid.NewGuid(), ValidToken);
|
||||
@@ -150,10 +150,10 @@ public sealed class SourceIngressGrpcServiceTests
|
||||
[Fact]
|
||||
public async Task PushSource_WithoutToken_IsUnauthenticated()
|
||||
{
|
||||
var store = new FakePipelineStore();
|
||||
var store = new TestPipelineStore();
|
||||
var registry = TestRegistry.With(Tenant(TenantA));
|
||||
|
||||
await RunAsync(registry, services => services.AddScoped<IPipelineStore>(_ => store), async channel =>
|
||||
await RunAsync(registry, services => services.AddScoped<IPipelineStore>(_ => store.Store), async channel =>
|
||||
{
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||
() => PushAsync(channel, Request("текст", externalId: "1"), TenantA, null));
|
||||
@@ -167,10 +167,10 @@ public sealed class SourceIngressGrpcServiceTests
|
||||
[Fact]
|
||||
public async Task PushSource_WithWrongToken_IsUnauthenticated()
|
||||
{
|
||||
var store = new FakePipelineStore();
|
||||
var store = new TestPipelineStore();
|
||||
var registry = TestRegistry.With(Tenant(TenantA));
|
||||
|
||||
await RunAsync(registry, services => services.AddScoped<IPipelineStore>(_ => store), async channel =>
|
||||
await RunAsync(registry, services => services.AddScoped<IPipelineStore>(_ => store.Store), async channel =>
|
||||
{
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||
() => PushAsync(channel, Request("текст", externalId: "2"), TenantA, "wrong-token"));
|
||||
@@ -184,7 +184,7 @@ public sealed class SourceIngressGrpcServiceTests
|
||||
[Fact]
|
||||
public async Task PushSource_UnsetEnvToken_FailsClosed()
|
||||
{
|
||||
var store = new FakePipelineStore();
|
||||
var store = new TestPipelineStore();
|
||||
var registry = TestRegistry.With(Tenant(TenantA));
|
||||
|
||||
await TelegramIngressTestHost.RunAsync(
|
||||
@@ -192,7 +192,7 @@ public sealed class SourceIngressGrpcServiceTests
|
||||
configureServices: services =>
|
||||
{
|
||||
services.AddSingleton<ITenantRepository>(registry);
|
||||
services.AddScoped<IPipelineStore>(_ => store);
|
||||
services.AddScoped<IPipelineStore>(_ => store.Store);
|
||||
},
|
||||
scenario: async channel =>
|
||||
{
|
||||
@@ -208,10 +208,10 @@ public sealed class SourceIngressGrpcServiceTests
|
||||
[Fact]
|
||||
public async Task PushSource_WithoutTenantIdMetadata_IsUnauthenticated()
|
||||
{
|
||||
var store = new FakePipelineStore();
|
||||
var store = new TestPipelineStore();
|
||||
var registry = TestRegistry.With(Tenant(TenantA));
|
||||
|
||||
await RunAsync(registry, services => services.AddScoped<IPipelineStore>(_ => store), async channel =>
|
||||
await RunAsync(registry, services => services.AddScoped<IPipelineStore>(_ => store.Store), async channel =>
|
||||
{
|
||||
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||
() => PushAsync(channel, Request("текст", externalId: "4"), null, ValidToken));
|
||||
|
||||
@@ -11,12 +11,12 @@ using Deal.Modules.Pipeline.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Services;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.SharedKernel.Tenants.Abstractions;
|
||||
using Deal.SharedKernel.Tenants.Models;
|
||||
using Deal.Tests.Unit.Contracts;
|
||||
using Deal.Tests.Unit.Modules.Cards;
|
||||
using Deal.Tests.Unit.Modules.Kanban;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
using Deal.Tests.Unit.Modules.Tenants;
|
||||
@@ -42,9 +42,9 @@ public sealed class StorageTickSchedulerTests
|
||||
[Fact]
|
||||
public async Task RunCycle_TicksEveryTenantInOwnScopeAndPublishesToastToEachTenantChannel()
|
||||
{
|
||||
FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old");
|
||||
FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
|
||||
var settings = new FakeSettingsStore();
|
||||
TestKanjStore storeA = StoreWithExpiredInboxCard("l_a_old");
|
||||
TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
|
||||
var settings = new TestSettingsStore();
|
||||
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
|
||||
var tenantContext = new TenantContext();
|
||||
await using ServiceProvider provider = BuildProvider(tenants, tenantContext, storeA, storeB, settings);
|
||||
@@ -70,10 +70,10 @@ public sealed class StorageTickSchedulerTests
|
||||
[Fact]
|
||||
public async Task RunCycle_ZeroCountersTenant_PublishesToastOnlyToTenantWithChanges()
|
||||
{
|
||||
FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old");
|
||||
var storeB = new FakeKanjStore();
|
||||
TestKanjStore storeA = StoreWithExpiredInboxCard("l_a_old");
|
||||
var storeB = new TestKanjStore();
|
||||
storeB.SeedCard(Card("l_b_fresh", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromHours(1))));
|
||||
var settings = new FakeSettingsStore();
|
||||
var settings = new TestSettingsStore();
|
||||
var tenantContext = new TenantContext();
|
||||
await using ServiceProvider provider = BuildProvider(
|
||||
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)),
|
||||
@@ -99,16 +99,16 @@ public sealed class StorageTickSchedulerTests
|
||||
public async Task RunCycle_TenantTickFailure_DoesNotAbortOtherTenants()
|
||||
{
|
||||
// У тенанта A настройки падают (имитация сбоя схемы/БД) — тик A логирует ошибку, B обрабатывается.
|
||||
FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
|
||||
TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
|
||||
var tenantContext = new TenantContext();
|
||||
var settingsByTenant = new Dictionary<Guid, ISettingsStore>
|
||||
{
|
||||
[TenantA] = new ThrowingSettingsStore(),
|
||||
[TenantB] = new FakeSettingsStore(),
|
||||
[TenantB] = new TestSettingsStore().Store,
|
||||
};
|
||||
var storesByTenant = new Dictionary<Guid, FakeKanjStore>
|
||||
var storesByTenant = new Dictionary<Guid, TestKanjStore>
|
||||
{
|
||||
[TenantA] = new FakeKanjStore(),
|
||||
[TenantA] = new TestKanjStore(),
|
||||
[TenantB] = storeB,
|
||||
};
|
||||
await using ServiceProvider provider = BuildProvider(
|
||||
@@ -137,7 +137,7 @@ public sealed class StorageTickSchedulerTests
|
||||
await using ServiceProvider provider = BuildProvider(
|
||||
new ThrowingTenantRepository(),
|
||||
tenantContext,
|
||||
new Dictionary<Guid, FakeKanjStore>(),
|
||||
new Dictionary<Guid, TestKanjStore>(),
|
||||
new Dictionary<Guid, ISettingsStore>());
|
||||
StorageTickScheduler scheduler = CreateScheduler(provider);
|
||||
|
||||
@@ -150,19 +150,19 @@ public sealed class StorageTickSchedulerTests
|
||||
[Fact]
|
||||
public async Task RunCycle_PurgesExpiredRejectedRowsAndPublishesRejectedPurgeToast()
|
||||
{
|
||||
var kanjStore = new FakeKanjStore();
|
||||
var settings = new FakeSettingsStore();
|
||||
var kanjStore = new TestKanjStore();
|
||||
var settings = new TestSettingsStore();
|
||||
var tenantContext = new TenantContext();
|
||||
var pipelineStoreA = new FakePipelineStore();
|
||||
var pipelineStoreA = new TestPipelineStore();
|
||||
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
pipelineStoreA.SeedRejected(Rejected("r_old_a", (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds)));
|
||||
pipelineStoreA.SeedRejected(Rejected("r_fresh", (long)(nowMs - TimeSpan.FromDays(1).TotalMilliseconds)));
|
||||
await using ServiceProvider provider = BuildProvider(
|
||||
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
|
||||
tenantContext,
|
||||
new Dictionary<Guid, FakeKanjStore> { [TenantA] = kanjStore, [TenantB] = new FakeKanjStore() },
|
||||
new Dictionary<Guid, ISettingsStore> { [TenantA] = settings, [TenantB] = settings },
|
||||
new Dictionary<Guid, FakePipelineStore> { [TenantA] = pipelineStoreA, [TenantB] = new FakePipelineStore() });
|
||||
new Dictionary<Guid, TestKanjStore> { [TenantA] = kanjStore, [TenantB] = new TestKanjStore() },
|
||||
new Dictionary<Guid, ISettingsStore> { [TenantA] = settings.Store, [TenantB] = settings.Store },
|
||||
new Dictionary<Guid, TestPipelineStore> { [TenantA] = pipelineStoreA, [TenantB] = new TestPipelineStore() });
|
||||
|
||||
SseBroker broker = provider.GetRequiredService<SseBroker>();
|
||||
SseSubscription subscriptionA = broker.Subscribe(TenantA);
|
||||
@@ -181,18 +181,18 @@ public sealed class StorageTickSchedulerTests
|
||||
[Fact]
|
||||
public async Task RunCycle_DueReminder_PublishesReminderDueToTenantChannelAndMarksFired()
|
||||
{
|
||||
var cardStoreA = new FakeKanjStore();
|
||||
var cardStoreA = new TestKanjStore();
|
||||
cardStoreA.SeedCard(HoldCard("c_a_past", title: "Отложенный бот", reminderAtMs: NowMs() - 60_000));
|
||||
cardStoreA.SeedCard(HoldCard("c_a_future", title: "Будущий", reminderAtMs: NowMs() + 60_000));
|
||||
cardStoreA.SeedCard(HoldCard("c_a_work", stage: "work", title: "В работе", reminderAtMs: NowMs() - 60_000));
|
||||
var cardStoreB = new FakeKanjStore();
|
||||
var cardStoreB = new TestKanjStore();
|
||||
var tenantContext = new TenantContext();
|
||||
await using ServiceProvider provider = BuildProvider(
|
||||
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
|
||||
tenantContext,
|
||||
new Dictionary<Guid, FakeKanjStore> { [TenantA] = cardStoreA, [TenantB] = cardStoreB },
|
||||
new Dictionary<Guid, ISettingsStore> { [TenantA] = new FakeSettingsStore(), [TenantB] = new FakeSettingsStore() },
|
||||
new Dictionary<Guid, FakePipelineStore> { [TenantA] = new(), [TenantB] = new() });
|
||||
new Dictionary<Guid, TestKanjStore> { [TenantA] = cardStoreA, [TenantB] = cardStoreB },
|
||||
new Dictionary<Guid, ISettingsStore> { [TenantA] = new TestSettingsStore().Store, [TenantB] = new TestSettingsStore().Store },
|
||||
new Dictionary<Guid, TestPipelineStore> { [TenantA] = new(), [TenantB] = new() });
|
||||
|
||||
SseBroker broker = provider.GetRequiredService<SseBroker>();
|
||||
SseSubscription subscriptionA = broker.Subscribe(TenantA);
|
||||
@@ -210,24 +210,24 @@ public sealed class StorageTickSchedulerTests
|
||||
Assert.False(subscriptionB.Events.TryRead(out _));
|
||||
|
||||
// «Выстрелившее» помечено fired: повторная выборка due пуста (признак держит строка БД).
|
||||
Assert.Empty(await cardStoreA.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None));
|
||||
Assert.Empty(await cardStoreA.Store.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None));
|
||||
Assert.False(tenantContext.HasTenant);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunCycle_RemindersDisabled_ClearsExpiredAndPublishesNoReminderDue()
|
||||
{
|
||||
var cardStoreA = new FakeKanjStore();
|
||||
var cardStoreA = new TestKanjStore();
|
||||
cardStoreA.SeedCard(HoldCard("c_past", title: "Старое", reminderAtMs: NowMs() - 60_000));
|
||||
var settingsA = new FakeSettingsStore();
|
||||
var settingsA = new TestSettingsStore();
|
||||
settingsA.Preload(SettingsKeys.RemindersEnabled, "false");
|
||||
var tenantContext = new TenantContext();
|
||||
await using ServiceProvider provider = BuildProvider(
|
||||
new TestTenantRepository(Tenant(TenantA)).Repository,
|
||||
tenantContext,
|
||||
new Dictionary<Guid, FakeKanjStore> { [TenantA] = cardStoreA },
|
||||
new Dictionary<Guid, ISettingsStore> { [TenantA] = settingsA },
|
||||
new Dictionary<Guid, FakePipelineStore> { [TenantA] = new() });
|
||||
new Dictionary<Guid, TestKanjStore> { [TenantA] = cardStoreA },
|
||||
new Dictionary<Guid, ISettingsStore> { [TenantA] = settingsA.Store },
|
||||
new Dictionary<Guid, TestPipelineStore> { [TenantA] = new() });
|
||||
|
||||
SseBroker broker = provider.GetRequiredService<SseBroker>();
|
||||
SseSubscription subscriptionA = broker.Subscribe(TenantA);
|
||||
@@ -245,15 +245,15 @@ public sealed class StorageTickSchedulerTests
|
||||
{
|
||||
// У тенанта A проверка напоминаний падает (имитация сбоя схемы/БД на ListDueAsync) — ветка логируется
|
||||
// и тик A завершается, тенант B обрабатывается (автоархив + тост) — проход жив.
|
||||
var cardStoreA = new ThrowingDueKanjStore();
|
||||
FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
|
||||
var cardStoreA = new TestKanjStore(throwOnDueReminders: true);
|
||||
TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
|
||||
var tenantContext = new TenantContext();
|
||||
await using ServiceProvider provider = BuildProvider(
|
||||
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
|
||||
tenantContext,
|
||||
new Dictionary<Guid, FakeKanjStore> { [TenantA] = cardStoreA, [TenantB] = storeB },
|
||||
new Dictionary<Guid, ISettingsStore> { [TenantA] = new FakeSettingsStore(), [TenantB] = new FakeSettingsStore() },
|
||||
new Dictionary<Guid, FakePipelineStore> { [TenantA] = new(), [TenantB] = new() });
|
||||
new Dictionary<Guid, TestKanjStore> { [TenantA] = cardStoreA, [TenantB] = storeB },
|
||||
new Dictionary<Guid, ISettingsStore> { [TenantA] = new TestSettingsStore().Store, [TenantB] = new TestSettingsStore().Store },
|
||||
new Dictionary<Guid, TestPipelineStore> { [TenantA] = new(), [TenantB] = new() });
|
||||
|
||||
SseBroker broker = provider.GetRequiredService<SseBroker>();
|
||||
SseSubscription subscriptionA = broker.Subscribe(TenantA);
|
||||
@@ -281,15 +281,15 @@ public sealed class StorageTickSchedulerTests
|
||||
private static ServiceProvider BuildProvider(
|
||||
TestTenantRepository tenants,
|
||||
TenantContext tenantContext,
|
||||
FakeKanjStore storeA,
|
||||
FakeKanjStore storeB,
|
||||
FakeSettingsStore settings)
|
||||
TestKanjStore storeA,
|
||||
TestKanjStore storeB,
|
||||
TestSettingsStore settings)
|
||||
{
|
||||
return BuildProvider(
|
||||
tenants.Repository,
|
||||
tenantContext,
|
||||
new Dictionary<Guid, FakeKanjStore> { [TenantA] = storeA, [TenantB] = storeB },
|
||||
new Dictionary<Guid, ISettingsStore> { [TenantA] = settings, [TenantB] = settings });
|
||||
new Dictionary<Guid, TestKanjStore> { [TenantA] = storeA, [TenantB] = storeB },
|
||||
new Dictionary<Guid, ISettingsStore> { [TenantA] = settings.Store, [TenantB] = settings.Store });
|
||||
}
|
||||
|
||||
// Строит DI-провайдер теста по явным картам тенант → хранилище/настройки.
|
||||
@@ -302,14 +302,14 @@ public sealed class StorageTickSchedulerTests
|
||||
private static ServiceProvider BuildProvider(
|
||||
ITenantRepository tenants,
|
||||
TenantContext tenantContext,
|
||||
Dictionary<Guid, FakeKanjStore> storesByTenant,
|
||||
Dictionary<Guid, TestKanjStore> storesByTenant,
|
||||
Dictionary<Guid, ISettingsStore> settingsByTenant,
|
||||
Dictionary<Guid, FakePipelineStore>? pipelineStoresByTenant = null)
|
||||
Dictionary<Guid, TestPipelineStore>? pipelineStoresByTenant = null)
|
||||
{
|
||||
pipelineStoresByTenant ??= new Dictionary<Guid, FakePipelineStore>
|
||||
pipelineStoresByTenant ??= new Dictionary<Guid, TestPipelineStore>
|
||||
{
|
||||
[TenantA] = new FakePipelineStore(),
|
||||
[TenantB] = new FakePipelineStore(),
|
||||
[TenantA] = new TestPipelineStore(),
|
||||
[TenantB] = new TestPipelineStore(),
|
||||
};
|
||||
|
||||
var services = new ServiceCollection();
|
||||
@@ -319,10 +319,10 @@ public sealed class StorageTickSchedulerTests
|
||||
services.AddSingleton(tenants);
|
||||
// Тенант-scoped адаптеры: реальные строят TenantDbContext по схеме текущего тенанта — фейк
|
||||
// выбирает хранилище по тому же ITenantContext, который планировщик заполняет SetTenant.
|
||||
services.AddScoped<ICardStore>(provider => storesByTenant[TenantOf(provider)]);
|
||||
services.AddScoped<ICardStore>(provider => storesByTenant[TenantOf(provider)].Store);
|
||||
services.AddScoped<ISettingsStore>(provider => settingsByTenant[TenantOf(provider)]);
|
||||
services.AddScoped<IPipelineStore>(provider => pipelineStoresByTenant[TenantOf(provider)]);
|
||||
services.AddSingleton<IMlClient>(new FakeMlClient());
|
||||
services.AddScoped<IPipelineStore>(provider => pipelineStoresByTenant[TenantOf(provider)].Store);
|
||||
services.AddSingleton<IMlClient>(new TestMlClient().Client);
|
||||
services.AddSingleton<IFileStorage>(new TestFileStorage().Storage);
|
||||
services.AddScoped<PipelineIngestService>();
|
||||
services.AddScoped<PipelineProcessingService>();
|
||||
@@ -359,17 +359,7 @@ public sealed class StorageTickSchedulerTests
|
||||
private static TenantRecordDto Tenant(Guid id) =>
|
||||
new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow);
|
||||
|
||||
// Хранилище карточек со сбоем выборки due-напоминаний: ListDueRemindersAsync бросает (сценарий
|
||||
// «БД/схема недоступны» на проверке напоминаний — ветка логируется, тик тенанта/проход живы, как в
|
||||
// AdminTickOrchestratorTests).
|
||||
private sealed class ThrowingDueKanjStore : FakeKanjStore
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public override Task<IReadOnlyList<CardReminderDueDto>> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct)
|
||||
{
|
||||
throw new InvalidOperationException("Тестовый сбой выборки due-напоминаний (ListDueRemindersAsync).");
|
||||
}
|
||||
}
|
||||
// Сбой выборки due-напоминаний задаётся флагом throwOnDueReminders в TestKanjStore.
|
||||
|
||||
// Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем.
|
||||
private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
@@ -394,9 +384,9 @@ public sealed class StorageTickSchedulerTests
|
||||
// Хранилище с просроченной карточкой «Неразобранного» — кандидатом автоархива дефолтного тика.
|
||||
// cardId: Id карточки.
|
||||
// Возвращает: Фейк-хранилище с одной старой карточкой inbox.
|
||||
private static FakeKanjStore StoreWithExpiredInboxCard(string cardId)
|
||||
private static TestKanjStore StoreWithExpiredInboxCard(string cardId)
|
||||
{
|
||||
var store = new FakeKanjStore();
|
||||
var store = new TestKanjStore();
|
||||
store.SeedCard(Card(cardId, KanbanColumns.Inbox, ReceivedAtMsAgo(ExpiredAge)));
|
||||
return store;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ using Deal.Api.Events;
|
||||
using Deal.Grpc.Telegram;
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using Deal.Tests.Unit.Support;
|
||||
using Deal.Modules.Telegram.Application;
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
@@ -61,7 +62,7 @@ public sealed class TelegramIngressServiceTests
|
||||
public async Task SyncDialogs_AutoMonitorNewDisabled_ReturnsEmptyMonitoredMirror()
|
||||
{
|
||||
var registry = TestRegistry.With(Tenant(TenantA));
|
||||
var settings = new FakeSettingsStore();
|
||||
var settings = new TestSettingsStore();
|
||||
settings.Preload(SettingsKeys.AutoMonitorNew, "false");
|
||||
var dialogs = new TestTelegramStore();
|
||||
|
||||
@@ -69,7 +70,7 @@ public sealed class TelegramIngressServiceTests
|
||||
registry,
|
||||
services =>
|
||||
{
|
||||
services.AddScoped<ISettingsStore>(_ => settings);
|
||||
services.AddScoped<ISettingsStore>(_ => settings.Store);
|
||||
services.AddScoped<ITelegramStore>(_ => dialogs.Store);
|
||||
},
|
||||
async channel =>
|
||||
@@ -91,7 +92,7 @@ public sealed class TelegramIngressServiceTests
|
||||
[Fact]
|
||||
public async Task ReportStatus_PersistsStatusAndPublishesTransitionToasts()
|
||||
{
|
||||
var settings = new FakeSettingsStore();
|
||||
var settings = new TestSettingsStore();
|
||||
var registry = TestRegistry.With(Tenant(TenantA));
|
||||
var broker = new SseBroker();
|
||||
|
||||
@@ -100,7 +101,7 @@ public sealed class TelegramIngressServiceTests
|
||||
services =>
|
||||
{
|
||||
services.AddSingleton<SseBroker>(broker);
|
||||
services.AddScoped<ISettingsStore>(_ => settings);
|
||||
services.AddScoped<ISettingsStore>(_ => settings.Store);
|
||||
},
|
||||
async channel =>
|
||||
{
|
||||
@@ -138,12 +139,12 @@ public sealed class TelegramIngressServiceTests
|
||||
[Fact]
|
||||
public async Task ReportStatus_UnknownTenant_NotSavedWithoutRpcError()
|
||||
{
|
||||
var settings = new FakeSettingsStore();
|
||||
var settings = new TestSettingsStore();
|
||||
var registry = TestRegistry.With(Tenant(TenantA));
|
||||
|
||||
await RunAsync(
|
||||
registry,
|
||||
services => services.AddScoped<ISettingsStore>(_ => settings),
|
||||
services => services.AddScoped<ISettingsStore>(_ => settings.Store),
|
||||
async channel =>
|
||||
{
|
||||
var client = new IngressService.IngressServiceClient(channel);
|
||||
@@ -261,7 +262,7 @@ public sealed class TelegramIngressServiceTests
|
||||
// phase: Ожидаемая фаза.
|
||||
// connected: Ожидаемый флаг connected.
|
||||
private static void AssertStatusStored(
|
||||
FakeSettingsStore settings,
|
||||
TestSettingsStore settings,
|
||||
string phase,
|
||||
bool connected)
|
||||
{
|
||||
|
||||
@@ -99,7 +99,7 @@ internal static class TelegramIngressTestHost
|
||||
.AddCheck("ready", () => HealthCheckResult.Healthy("хост готов"));
|
||||
}
|
||||
|
||||
builder.Services.AddScoped<ISettingsStore>(_ => new FakeSettingsStore());
|
||||
builder.Services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store);
|
||||
builder.Services.AddScoped<ITelegramStore>(_ => new TestTelegramStore().Store);
|
||||
builder.Services.AddScoped<ITelegramGateway>(_ => new TestTelegramGateway().Gateway);
|
||||
builder.Services.AddTelegramModule();
|
||||
|
||||
@@ -71,7 +71,7 @@ public sealed class TelegramSourceIngestObserverTests
|
||||
var store = new TestTelegramStore();
|
||||
var dialogs = new DialogsService(
|
||||
store.Store,
|
||||
new FakeSettingsStore(),
|
||||
new TestSettingsStore().Store,
|
||||
new TestTelegramGateway().Gateway,
|
||||
NullLogger<DialogsService>.Instance);
|
||||
var observer = new TelegramSourceIngestObserver(dialogs, NullLogger<TelegramSourceIngestObserver>.Instance);
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using Deal.Modules.Tenants.Application.Services;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Deal.Tests.Unit.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Подставка <see cref="IAuditLogStore"/> на списках: сервисы получают NSubstitute-подставку
|
||||
/// (<see cref="Store"/>), тесты проверяют записи через <see cref="Records"/>.
|
||||
/// </summary>
|
||||
public sealed class TestAuditLogStore
|
||||
{
|
||||
private readonly List<AuditRecordDto> _records = [];
|
||||
|
||||
/// <summary>
|
||||
/// Подставка порта аудита (создаётся в конструкторе).
|
||||
/// </summary>
|
||||
public IAuditLogStore Store { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Записи хранилища в порядке добавления.
|
||||
/// </summary>
|
||||
public IReadOnlyList<AuditRecordDto> Records => _records;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт подставку с пустым журналом.
|
||||
/// </summary>
|
||||
public TestAuditLogStore()
|
||||
{
|
||||
Store = Substitute.For<IAuditLogStore>();
|
||||
Store.When(s => s.AppendAsync(Arg.Any<AuditRecordDto>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => _records.Add(ci.Arg<AuditRecordDto>() with { Id = _records.Count + 1 }));
|
||||
Store.QueryAsync(Arg.Any<AuditQueryDto>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => Query(ci.Arg<AuditQueryDto>()));
|
||||
Store.CountAsync(Arg.Any<AuditQueryDto>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => ApplyFilters(ci.Arg<AuditQueryDto>()).Count());
|
||||
Store.PurgeOlderThanAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => _records.RemoveAll(record => record.At < ci.ArgAt<DateTimeOffset>(0)));
|
||||
}
|
||||
|
||||
private IReadOnlyList<AuditRecordDto> Query(AuditQueryDto filter)
|
||||
{
|
||||
return ApplyFilters(filter)
|
||||
.OrderByDescending(r => r.At)
|
||||
.Skip(Math.Max(0, filter.Offset))
|
||||
.Take(Math.Max(1, Math.Min(AuditService.MaxQueryLimit, filter.Limit)))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private IEnumerable<AuditRecordDto> ApplyFilters(AuditQueryDto filter) =>
|
||||
_records.Where(r =>
|
||||
(filter.EventType is null || r.EventType == filter.EventType) &&
|
||||
(filter.ActorType is null || r.ActorType == filter.ActorType) &&
|
||||
(filter.TenantId is null || r.TenantId == filter.TenantId) &&
|
||||
(filter.ActorId is null || r.ActorId == filter.ActorId) &&
|
||||
(filter.From is null || r.At >= filter.From.Value) &&
|
||||
(filter.To is null || r.At <= filter.To.Value));
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Deal.Tests.Unit.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Подставка <see cref="IAuthStore"/> на списках: сервисы получают NSubstitute-подставку
|
||||
/// (<see cref="Store"/>), тесты сеют/проверяют пользователей и сессии через
|
||||
/// <see cref="AddUser"/>, <see cref="AddSession"/>, <see cref="Users"/>, <see cref="Sessions"/>,
|
||||
/// журнал мутирующих вызовов — <see cref="Calls"/>.
|
||||
/// </summary>
|
||||
public sealed class TestAuthStore
|
||||
{
|
||||
private readonly List<StoredUserDto> _users = [];
|
||||
private readonly List<SessionDto> _sessions = [];
|
||||
private readonly List<string> _calls = [];
|
||||
|
||||
/// <summary>
|
||||
/// Подставка порта аутентификации (создаётся в конструкторе).
|
||||
/// </summary>
|
||||
public IAuthStore Store { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Пользователи хранилища.
|
||||
/// </summary>
|
||||
public IReadOnlyList<StoredUserDto> Users => _users;
|
||||
|
||||
/// <summary>
|
||||
/// Сессии хранилища.
|
||||
/// </summary>
|
||||
public IReadOnlyList<SessionDto> Sessions => _sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Журнал мутирующих вызовов в порядке их совершения.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Calls => _calls;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт подставку с пустыми списками.
|
||||
/// </summary>
|
||||
public TestAuthStore()
|
||||
{
|
||||
Store = Substitute.For<IAuthStore>();
|
||||
Store.FindUserByLoginAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => _users.SingleOrDefault(u => u.Login == ci.ArgAt<string>(0)));
|
||||
Store.FindSessionByTokenHashAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => _sessions.SingleOrDefault(s => s.TokenHash == ci.ArgAt<string>(0)));
|
||||
Store.FindUserByIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => FindById(ci.ArgAt<Guid>(0)));
|
||||
Store.ListUsersByTenantIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => ListByTenantId(ci.ArgAt<Guid>(0)));
|
||||
Store.When(s => s.CreateUserAsync(Arg.Any<StoredUserDto>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci =>
|
||||
{
|
||||
StoredUserDto user = ci.Arg<StoredUserDto>();
|
||||
_users.Add(user);
|
||||
_calls.Add($"create-user:{user.Id}");
|
||||
});
|
||||
Store.When(s => s.CreateSessionAsync(Arg.Any<SessionDto>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci =>
|
||||
{
|
||||
SessionDto session = ci.Arg<SessionDto>();
|
||||
_sessions.Add(session);
|
||||
_calls.Add($"create-session:{session.UserId}");
|
||||
});
|
||||
Store.When(s => s.DeleteSessionAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci =>
|
||||
{
|
||||
_sessions.RemoveAll(s => s.TokenHash == ci.ArgAt<string>(0));
|
||||
_calls.Add($"delete-session:{ci.ArgAt<string>(0)}");
|
||||
});
|
||||
Store.When(s => s.DeleteSessionsByUserIdAsync(Arg.Any<Guid>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci =>
|
||||
{
|
||||
_sessions.RemoveAll(s => s.UserId == ci.ArgAt<Guid>(0));
|
||||
_calls.Add($"delete-user-sessions:{ci.ArgAt<Guid>(0)}");
|
||||
});
|
||||
Store.When(s => s.UpdatePasswordHashAsync(
|
||||
Arg.Any<Guid>(), Arg.Any<string>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => UpdatePasswordHash(ci.ArgAt<Guid>(0), ci.ArgAt<string>(1)));
|
||||
Store.When(s => s.DeleteExpiredSessionsAsync(Arg.Any<CancellationToken>()))
|
||||
.Do(_ =>
|
||||
{
|
||||
_sessions.RemoveAll(s => s.ExpiresAt <= DateTimeOffset.UtcNow);
|
||||
_calls.Add("delete-expired");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Добавляет пользователя
|
||||
/// </summary>
|
||||
/// <param name="user">Пользователь.</param>
|
||||
public void AddUser(StoredUserDto user) => _users.Add(user);
|
||||
|
||||
/// <summary>
|
||||
/// Добавляет сессию
|
||||
/// </summary>
|
||||
/// <param name="session">Сессия.</param>
|
||||
public void AddSession(SessionDto session) => _sessions.Add(session);
|
||||
|
||||
private UserIdentityDto? FindById(Guid userId)
|
||||
{
|
||||
StoredUserDto? user = _users.SingleOrDefault(u => u.Id == userId);
|
||||
return user is null ? null : new UserIdentityDto(user.Id, user.Login, user.TenantId, user.Status);
|
||||
}
|
||||
|
||||
private IReadOnlyList<UserIdentityDto> ListByTenantId(Guid tenantId)
|
||||
{
|
||||
return _users
|
||||
.Where(u => u.TenantId == tenantId)
|
||||
.Select(u => new UserIdentityDto(u.Id, u.Login, u.TenantId, u.Status))
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private void UpdatePasswordHash(Guid userId, string passwordHash)
|
||||
{
|
||||
int index = _users.FindIndex(u => u.Id == userId);
|
||||
if (index >= 0)
|
||||
{
|
||||
StoredUserDto user = _users[index];
|
||||
_users[index] = user with { PasswordHash = passwordHash };
|
||||
}
|
||||
|
||||
_calls.Add($"update-password-hash:{userId}");
|
||||
}
|
||||
}
|
||||
+265
-293
@@ -2,120 +2,262 @@ using Deal.Modules.Cards.Application.Models;
|
||||
using Deal.Modules.Cards.Application.Sources;
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Deal.Tests.Unit.Modules.Cards;
|
||||
namespace Deal.Tests.Unit.Support;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory реализация <see cref="ICardStore"/> для unit-тестов сервисов единого домена карточки.
|
||||
/// Подставка <see cref="ICardStore"/> на списках: сервисы получают NSubstitute-подставку
|
||||
/// (<see cref="Store"/>), тесты сеют/проверяют состояние через <see cref="SeedBoard"/>,
|
||||
/// <see cref="SeedCard"/>, <see cref="AddCard"/>, <see cref="Boards"/>, <see cref="CardDtos"/>,
|
||||
/// <see cref="Moves"/>, <see cref="SearchCalls"/>. Сбой записи задаётся <see cref="FailAddCard"/>.
|
||||
/// </summary>
|
||||
public class FakeKanjStore : ICardStore
|
||||
public sealed class TestKanjStore
|
||||
{
|
||||
private readonly List<ContainerDto> _boards = [];
|
||||
private readonly List<CardDto> _cards = [];
|
||||
private readonly List<CardMoveDto> _moves = [];
|
||||
private readonly List<(string Query, int Limit)> _searchCalls = [];
|
||||
|
||||
private readonly HashSet<string> _firedById = new(StringComparer.Ordinal);
|
||||
|
||||
private readonly Dictionary<string, DateTimeOffset> _archivedAtById = new(StringComparer.Ordinal);
|
||||
private readonly bool _throwOnDueReminders;
|
||||
|
||||
/// <summary>
|
||||
/// Доски фейка (копия на момент обращения) — проверки BoardsServiceTests и LocalColumnSuggesterTests.
|
||||
/// Подставка порта единого домена карточки (создаётся в конструкторе).
|
||||
/// </summary>
|
||||
public ICardStore Store { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Доски хранилища (копия на момент обращения).
|
||||
/// </summary>
|
||||
public IReadOnlyList<ContainerDto> Boards => _boards.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Карточки фейка как тройки
|
||||
/// Карточки хранилища как тройки (id, колонка, флаг «новая»).
|
||||
/// </summary>
|
||||
public IReadOnlyList<(string CardId, string Col, bool IsNew)> Cards =>
|
||||
_cards.Select(card => (card.Id, card.Col, card.IsNew)).ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Полные карточки фейка
|
||||
/// Полные карточки хранилища (копия на момент обращения).
|
||||
/// </summary>
|
||||
public IReadOnlyList<CardDto> CardDtos => _cards.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Флаг «сбой записи карточки»
|
||||
/// Флаг «сбой записи карточки».
|
||||
/// </summary>
|
||||
public bool FailAddCard { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Записи журнала CardMoves
|
||||
/// Записи журнала CardMoves.
|
||||
/// </summary>
|
||||
public IReadOnlyList<CardMoveDto> Moves => _moves.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Вызовы SearchCardsAsync как пары
|
||||
/// Вызовы SearchCardsAsync как пары (запрос, лимит).
|
||||
/// </summary>
|
||||
public IReadOnlyList<(string Query, int Limit)> SearchCalls => _searchCalls.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт подставку с пустыми списками.
|
||||
/// </summary>
|
||||
/// <param name="throwOnDueReminders">Сценарий сбоя выборки due-напоминаний: ListDueRemindersAsync бросает.</param>
|
||||
public TestKanjStore(bool throwOnDueReminders = false)
|
||||
{
|
||||
_throwOnDueReminders = throwOnDueReminders;
|
||||
Store = Substitute.For<ICardStore>();
|
||||
ConfigureContainers();
|
||||
ConfigureCards();
|
||||
ConfigureComments();
|
||||
ConfigureMoves();
|
||||
ConfigureStorage();
|
||||
ConfigureSelected();
|
||||
ConfigureReminders();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Кладёт доску напрямую
|
||||
/// </summary>
|
||||
/// <param name="board">Доска как если бы была сохранена в БД.</param>
|
||||
public void SeedBoard(ContainerDto board)
|
||||
{
|
||||
_boards.Add(board);
|
||||
}
|
||||
public void SeedBoard(ContainerDto board) => _boards.Add(board);
|
||||
|
||||
/// <summary>
|
||||
/// Кладёт карточку в колонку.
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки.</param>
|
||||
/// <param name="col">Колонка (inbox/доска).</param>
|
||||
public void AddCard(string cardId, string col)
|
||||
{
|
||||
SeedCard(new CardDto { Id = cardId, Col = col });
|
||||
}
|
||||
public void AddCard(string cardId, string col) => SeedCard(new CardDto { Id = cardId, Col = col });
|
||||
|
||||
/// <summary>
|
||||
/// Кладёт полную карточку.
|
||||
/// </summary>
|
||||
/// <param name="card">Карточка как если бы была сохранена в БД (комментарии — приложенным массивом).</param>
|
||||
public void SeedCard(CardDto card)
|
||||
public void SeedCard(CardDto card) => _cards.Add(card);
|
||||
|
||||
/// <summary>
|
||||
/// Задаёт метку архивации карточки
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки.</param>
|
||||
/// <param name="archivedAt">Метка архивации (когда карточка ушла в архив).</param>
|
||||
public void SetArchivedAt(string cardId, DateTimeOffset archivedAt) => _archivedAtById[cardId] = archivedAt;
|
||||
|
||||
/// <summary>
|
||||
/// Метка архивации карточки — проверка archived_at после автоархива тика.
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки.</param>
|
||||
/// <returns>Метка архивации либо null — карточки нет/не архивирована/метка сброшена.</returns>
|
||||
public DateTimeOffset? ArchivedAtOf(string cardId) =>
|
||||
_archivedAtById.TryGetValue(cardId, out DateTimeOffset archivedAt) ? archivedAt : null;
|
||||
|
||||
private void ConfigureContainers()
|
||||
{
|
||||
_cards.Add(card);
|
||||
Store.ListContainersAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => ListContainers(ci.ArgAt<string?>(0)));
|
||||
Store.GetContainerAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => _boards.FirstOrDefault(board => board.Id == ci.ArgAt<string>(0)));
|
||||
Store.When(s => s.CreateContainerAsync(Arg.Any<ContainerDto>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => _boards.Add(ci.Arg<ContainerDto>()));
|
||||
Store.When(s => s.UpdateContainerAsync(Arg.Any<ContainerDto>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => UpdateContainer(ci.Arg<ContainerDto>()));
|
||||
Store.DeleteContainerAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => DeleteContainer(ci.ArgAt<string>(0)));
|
||||
Store.When(s => s.ReorderContainersAsync(
|
||||
Arg.Any<string>(), Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => ReorderContainers(ci.ArgAt<string>(0), ci.ArgAt<IReadOnlyList<string>>(1)));
|
||||
}
|
||||
|
||||
// ── Контейнеры (колонки/стадии/зоны) ─────────────────────────────────
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<ContainerDto>> ListContainersAsync(string? space, CancellationToken ct)
|
||||
private void ConfigureCards()
|
||||
{
|
||||
IReadOnlyList<ContainerDto> ordered = _boards
|
||||
Store.ListCardsAsync(Arg.Any<CardsQuery>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => ListCards(ci.Arg<CardsQuery>()));
|
||||
Store.SearchCardsAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => SearchCards(ci.ArgAt<string>(0), ci.ArgAt<int>(1)));
|
||||
Store.GetCardAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => _cards.FirstOrDefault(card => card.Id == ci.ArgAt<string>(0)));
|
||||
Store.GetCardBySourceAsync(Arg.Any<SourceRef>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => GetCardBySource(ci.Arg<SourceRef>()));
|
||||
Store.When(s => s.AddCardAsync(Arg.Any<CardSnapshot>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => AddCard(ci.Arg<CardSnapshot>()));
|
||||
Store.When(s => s.UpdateColumnAsync(Arg.Any<CardColumnUpdateDto>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => UpdateColumn(ci.Arg<CardColumnUpdateDto>()));
|
||||
Store.ApplyReclassificationAsync(Arg.Any<CardReclassificationDto>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => ApplyReclassification(ci.Arg<CardReclassificationDto>()));
|
||||
Store.When(s => s.UpdateSeenAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => UpdateSeen(ci.ArgAt<string?>(0), ci.ArgAt<string?>(1)));
|
||||
Store.When(s => s.DeleteForeverAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => DeleteForever(ci.ArgAt<string>(0)));
|
||||
Store.ClearColAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => ClearCol(ci.ArgAt<string>(0)));
|
||||
Store.CountCardsByColAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(_ => CountCardsByCol());
|
||||
}
|
||||
|
||||
private void ConfigureComments()
|
||||
{
|
||||
Store.ListCommentsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => _cards.FirstOrDefault(card => card.Id == ci.ArgAt<string>(0))?.Comments
|
||||
?? Array.Empty<CardCommentDto>());
|
||||
Store.When(s => s.AddCommentAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => AddComment(
|
||||
ci.ArgAt<string>(0), ci.ArgAt<string>(1), ci.ArgAt<string>(2), ci.ArgAt<string>(3)));
|
||||
}
|
||||
|
||||
private void ConfigureMoves()
|
||||
{
|
||||
Store.When(s => s.AddMoveAsync(Arg.Any<CardMoveDto>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => _moves.Add(ci.Arg<CardMoveDto>()));
|
||||
Store.CountMovesAsync(Arg.Any<CancellationToken>()).Returns(_ => _moves.Count);
|
||||
Store.GetAiMarkupExamplesAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => GetAiMarkupExamples(ci.ArgAt<int>(0)));
|
||||
}
|
||||
|
||||
private void ConfigureStorage()
|
||||
{
|
||||
Store.ListArchiveCandidatesAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => ListArchiveCandidates(ci.ArgAt<DateTimeOffset>(0)));
|
||||
Store.ArchiveAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => Archive(ci.ArgAt<IReadOnlyList<string>>(0), ci.ArgAt<DateTimeOffset>(1)));
|
||||
Store.ListExpiredArchiveCandidatesAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => ListExpiredArchiveCandidates(ci.ArgAt<DateTimeOffset>(0)));
|
||||
Store.ListTrashCandidatesAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => ListTrashCandidates(ci.ArgAt<DateTimeOffset>(0)));
|
||||
Store.PurgeAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => Purge(ci.ArgAt<IReadOnlyList<string>>(0)));
|
||||
Store.ListCardsForConversionAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(_ => ListCardsForConversion());
|
||||
Store.When(s => s.UpdateConversionAsync(
|
||||
Arg.Any<string>(), Arg.Any<double?>(), Arg.Any<double?>(), Arg.Any<string>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => UpdateConversion(
|
||||
ci.ArgAt<string>(0), ci.ArgAt<double?>(1), ci.ArgAt<double?>(2), ci.ArgAt<string>(3)));
|
||||
}
|
||||
|
||||
private void ConfigureSelected()
|
||||
{
|
||||
Store.ListSelectedCardsAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => ListSelectedCards(ci.ArgAt<string?>(0)));
|
||||
Store.PatchCardAsync(Arg.Any<string>(), Arg.Any<CardPatch>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => PatchCard(ci.ArgAt<string>(0), ci.Arg<CardPatch>()));
|
||||
Store.AddLinkAsync(Arg.Any<string>(), Arg.Any<CardLinkDto>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => AddLink(ci.ArgAt<string>(0), ci.Arg<CardLinkDto>()));
|
||||
Store.RemoveLinkAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => RemoveLink(ci.ArgAt<string>(0), ci.ArgAt<string>(1)));
|
||||
Store.AddFileAsync(Arg.Any<string>(), Arg.Any<CardFileDto>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => AddFile(ci.ArgAt<string>(0), ci.Arg<CardFileDto>()));
|
||||
Store.RemoveFileAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => RemoveFile(ci.ArgAt<string>(0), ci.ArgAt<string>(1)));
|
||||
Store.MoveCardStageAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CardHistoryDto>(), Arg.Any<long>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => MoveCardStage(
|
||||
ci.ArgAt<string>(0), ci.ArgAt<string>(1), ci.Arg<CardHistoryDto>(), ci.ArgAt<long>(3)));
|
||||
}
|
||||
|
||||
private void ConfigureReminders()
|
||||
{
|
||||
Store.When(s => s.SetReminderAsync(Arg.Any<string>(), Arg.Any<long>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => SetReminder(ci.ArgAt<string>(0), ci.ArgAt<long>(1)));
|
||||
Store.When(s => s.ClearReminderAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => ClearReminder(ci.ArgAt<string>(0)));
|
||||
Store.ClearStageAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => ClearStage(ci.ArgAt<string>(0)));
|
||||
if (_throwOnDueReminders)
|
||||
{
|
||||
Store.ListDueRemindersAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns<Task<IReadOnlyList<CardReminderDueDto>>>(_ =>
|
||||
throw new InvalidOperationException("Тестовый сбой выборки due-напоминаний (ListDueRemindersAsync)."));
|
||||
}
|
||||
else
|
||||
{
|
||||
Store.ListDueRemindersAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => ListDueReminders(ci.ArgAt<DateTimeOffset>(0)));
|
||||
}
|
||||
|
||||
Store.When(s => s.MarkRemindersFiredAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => MarkRemindersFired(ci.ArgAt<IReadOnlyList<string>>(0)));
|
||||
Store.ClearExpiredRemindersAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => ClearExpiredReminders(ci.ArgAt<DateTimeOffset>(0)));
|
||||
Store.ListInboxWithSourceAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(_ => ListInboxWithSource());
|
||||
}
|
||||
|
||||
private IReadOnlyList<ContainerDto> ListContainers(string? space)
|
||||
{
|
||||
return _boards
|
||||
.Where(board => space is null || board.Space == space)
|
||||
.OrderBy(board => board.Suggested)
|
||||
.ThenBy(board => board.Order)
|
||||
.ToList();
|
||||
return Task.FromResult(ordered);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<ContainerDto?> GetContainerAsync(string containerId, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(_boards.FirstOrDefault(board => board.Id == containerId));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task CreateContainerAsync(ContainerDto container, CancellationToken ct)
|
||||
{
|
||||
_boards.Add(container);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateContainerAsync(ContainerDto container, CancellationToken ct)
|
||||
private void UpdateContainer(ContainerDto container)
|
||||
{
|
||||
int index = _boards.FindIndex(item => item.Id == container.Id);
|
||||
_boards[index] = container;
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> DeleteContainerAsync(string containerId, CancellationToken ct)
|
||||
private int DeleteContainer(string containerId)
|
||||
{
|
||||
List<CardDto> moved = _cards.Where(card => card.Col == containerId).ToList();
|
||||
int moved = _cards.Count(card => card.Col == containerId);
|
||||
for (int i = 0; i < _cards.Count; i++)
|
||||
{
|
||||
if (_cards[i].Col == containerId)
|
||||
@@ -125,54 +267,39 @@ public class FakeKanjStore : ICardStore
|
||||
}
|
||||
|
||||
_boards.RemoveAll(board => board.Id == containerId);
|
||||
return Task.FromResult(moved.Count);
|
||||
return moved;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ReorderContainersAsync(
|
||||
string space,
|
||||
IReadOnlyList<string> containerIds,
|
||||
CancellationToken ct)
|
||||
private void ReorderContainers(string space, IReadOnlyList<string> containerIds)
|
||||
{
|
||||
for (int i = 0; i < containerIds.Count; i++)
|
||||
{
|
||||
string containerId = containerIds[i];
|
||||
int index = _boards.FindIndex(board => board.Id == containerId && board.Space == space);
|
||||
int index = _boards.FindIndex(board => board.Id == containerIds[i] && board.Space == space);
|
||||
if (index >= 0)
|
||||
{
|
||||
_boards[index] = _boards[index] with { Order = i };
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── Карточки ─────────────────────────────────────────────────────────
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<CardDto>> ListCardsAsync(CardsQuery query, CancellationToken ct)
|
||||
private IReadOnlyList<CardDto> ListCards(CardsQuery query)
|
||||
{
|
||||
IEnumerable<CardDto> result = query.Col is null
|
||||
? _cards
|
||||
: _cards.Where(card => card.Col == query.Col);
|
||||
return Task.FromResult<IReadOnlyList<CardDto>>(
|
||||
result.OrderByDescending(card => card.ReceivedAtMs).ToList());
|
||||
return result.OrderByDescending(card => card.ReceivedAtMs).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<CardDto>> SearchCardsAsync(
|
||||
string q,
|
||||
int limit,
|
||||
CancellationToken ct)
|
||||
private IReadOnlyList<CardDto> SearchCards(string q, int limit)
|
||||
{
|
||||
_searchCalls.Add((q, limit));
|
||||
string lowered = q.Trim().ToLowerInvariant();
|
||||
if (lowered.Length == 0)
|
||||
{
|
||||
return Task.FromResult<IReadOnlyList<CardDto>>(Array.Empty<CardDto>());
|
||||
return Array.Empty<CardDto>();
|
||||
}
|
||||
|
||||
IReadOnlyList<CardDto> result = _cards
|
||||
return _cards
|
||||
.Where(card => card.Title.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal)
|
||||
|| card.Summary.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal)
|
||||
|| card.Contact.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal)
|
||||
@@ -180,38 +307,26 @@ public class FakeKanjStore : ICardStore
|
||||
.OrderByDescending(card => card.ReceivedAtMs)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
return Task.FromResult<IReadOnlyList<CardDto>>(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<CardDto?> GetCardAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(_cards.FirstOrDefault(card => card.Id == cardId));
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<CardDto?> GetCardBySourceAsync(
|
||||
SourceRef source,
|
||||
CancellationToken ct)
|
||||
private CardDto? GetCardBySource(SourceRef source)
|
||||
{
|
||||
if (string.IsNullOrEmpty(source.Kind))
|
||||
{
|
||||
return Task.FromResult<CardDto?>(null);
|
||||
return null;
|
||||
}
|
||||
|
||||
string externalId = source.ExternalId ?? string.Empty;
|
||||
string originRef = source.OriginRef ?? string.Empty;
|
||||
CardDto? match = _cards
|
||||
return _cards
|
||||
.Where(card => card.Source.Kind == source.Kind
|
||||
&& (card.Source.ExternalId ?? string.Empty) == externalId
|
||||
&& (card.Source.OriginRef ?? string.Empty) == originRef)
|
||||
.OrderByDescending(card => card.ReceivedAtMs)
|
||||
.FirstOrDefault();
|
||||
return Task.FromResult(match);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddCardAsync(CardSnapshot snapshot, CancellationToken ct)
|
||||
private void AddCard(CardSnapshot snapshot)
|
||||
{
|
||||
if (FailAddCard)
|
||||
{
|
||||
@@ -249,16 +364,14 @@ public class FakeKanjStore : ICardStore
|
||||
CreatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct)
|
||||
private void UpdateColumn(CardColumnUpdateDto update)
|
||||
{
|
||||
int index = _cards.FindIndex(card => card.Id == update.CardId);
|
||||
if (index < 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
return;
|
||||
}
|
||||
|
||||
_cards[index] = _cards[index] with
|
||||
@@ -276,17 +389,14 @@ public class FakeKanjStore : ICardStore
|
||||
{
|
||||
_archivedAtById.Remove(update.CardId);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct)
|
||||
private bool ApplyReclassification(CardReclassificationDto update)
|
||||
{
|
||||
int index = _cards.FindIndex(card => card.Id == update.CardId);
|
||||
if (index < 0)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
_cards[index] = _cards[index] with
|
||||
@@ -304,40 +414,28 @@ public class FakeKanjStore : ICardStore
|
||||
Contacts = update.Contacts,
|
||||
MatchHits = update.MatchHits,
|
||||
};
|
||||
return Task.FromResult(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateSeenAsync(
|
||||
string? cardId,
|
||||
string? col,
|
||||
CancellationToken ct)
|
||||
private void UpdateSeen(string? cardId, string? col)
|
||||
{
|
||||
for (int i = 0; i < _cards.Count; i++)
|
||||
{
|
||||
bool matches = cardId is not null
|
||||
? _cards[i].Id == cardId
|
||||
: col is null || _cards[i].Col == col;
|
||||
bool matches = cardId is not null ? _cards[i].Id == cardId : col is null || _cards[i].Col == col;
|
||||
if (matches && _cards[i].IsNew)
|
||||
{
|
||||
_cards[i] = _cards[i] with { IsNew = false };
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task DeleteForeverAsync(string cardId, CancellationToken ct)
|
||||
private void DeleteForever(string cardId)
|
||||
{
|
||||
// Удаляется карточка (с комментариями — они часть карточки в фейке); журнал CardMoves не трогаем.
|
||||
_cards.RemoveAll(card => card.Id == cardId);
|
||||
_archivedAtById.Remove(cardId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> ClearColAsync(string col, CancellationToken ct)
|
||||
private int ClearCol(string col)
|
||||
{
|
||||
List<string> removedIds = _cards.Where(card => card.Col == col).Select(card => card.Id).ToList();
|
||||
_cards.RemoveAll(card => card.Col == col);
|
||||
@@ -346,64 +444,33 @@ public class FakeKanjStore : ICardStore
|
||||
_archivedAtById.Remove(cardId);
|
||||
}
|
||||
|
||||
return Task.FromResult(removedIds.Count);
|
||||
return removedIds.Count;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyDictionary<string, CardColumnCountDto>> CountCardsByColAsync(CancellationToken ct)
|
||||
private IReadOnlyDictionary<string, CardColumnCountDto> CountCardsByCol()
|
||||
{
|
||||
IReadOnlyDictionary<string, CardColumnCountDto> result = _cards
|
||||
return _cards
|
||||
.GroupBy(card => card.Col)
|
||||
.ToDictionary(
|
||||
group => group.Key,
|
||||
group => new CardColumnCountDto(group.Count(), group.Count(card => card.IsNew)));
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
// ── Комментарии (LeadComments) ────────────────────────────────────────
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<CardCommentDto>> ListCommentsAsync(string cardId, CancellationToken ct)
|
||||
{
|
||||
CardDto? card = _cards.FirstOrDefault(item => item.Id == cardId);
|
||||
return Task.FromResult(card?.Comments ?? Array.Empty<CardCommentDto>());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddCommentAsync(
|
||||
string commentId,
|
||||
string cardId,
|
||||
string by,
|
||||
string text,
|
||||
CancellationToken ct)
|
||||
private void AddComment(string commentId, string cardId, string by, string text)
|
||||
{
|
||||
int index = _cards.FindIndex(card => card.Id == cardId);
|
||||
if (index < 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
return;
|
||||
}
|
||||
|
||||
// Метка времени свежего комментария — «только что» (адаптер считает HumanAge от CreatedAt = UtcNow).
|
||||
CardCommentDto comment = new(commentId, by, text, "только что");
|
||||
IReadOnlyList<CardCommentDto> updated = _cards[index].Comments.Concat(new[] { comment }).ToList();
|
||||
_cards[index] = _cards[index] with { Comments = updated };
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
// ── Журнал CardMoves (learning_log) ───────────────────────────────────
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task AddMoveAsync(CardMoveDto move, CancellationToken ct)
|
||||
{
|
||||
_moves.Add(move);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> CountMovesAsync(CancellationToken ct) => Task.FromResult(_moves.Count);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<AiMarkupExampleDto>> GetAiMarkupExamplesAsync(int limit, CancellationToken ct)
|
||||
private IReadOnlyList<AiMarkupExampleDto> GetAiMarkupExamples(int limit)
|
||||
{
|
||||
var examples = new List<AiMarkupExampleDto>();
|
||||
for (int index = _moves.Count - 1; index >= 0 && examples.Count < limit; index--)
|
||||
@@ -429,31 +496,24 @@ public class FakeKanjStore : ICardStore
|
||||
examples.Add(new AiMarkupExampleDto(sourceText, move.ToCol));
|
||||
}
|
||||
|
||||
return Task.FromResult<IReadOnlyList<AiMarkupExampleDto>>(examples);
|
||||
return examples;
|
||||
}
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<string>> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
|
||||
private IReadOnlyList<string> ListArchiveCandidates(DateTimeOffset receivedBeforeUtc)
|
||||
{
|
||||
var boardIds = new HashSet<string>(_boards.Select(board => board.Id), StringComparer.Ordinal);
|
||||
IReadOnlyList<string> result = _cards
|
||||
return _cards
|
||||
.Where(card => (card.Col == KanbanColumns.Inbox || boardIds.Contains(card.Col))
|
||||
&& ReceivedAtOf(card) < receivedBeforeUtc)
|
||||
.Select(card => card.Id)
|
||||
.ToList();
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> ArchiveAsync(
|
||||
IReadOnlyList<string> cardIds,
|
||||
DateTimeOffset archivedAt,
|
||||
CancellationToken ct)
|
||||
private int Archive(IReadOnlyList<string> cardIds, DateTimeOffset archivedAt)
|
||||
{
|
||||
if (cardIds.Count == 0)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
var ids = new HashSet<string>(cardIds, StringComparer.Ordinal);
|
||||
@@ -475,38 +535,32 @@ public class FakeKanjStore : ICardStore
|
||||
archived++;
|
||||
}
|
||||
|
||||
return Task.FromResult(archived);
|
||||
return archived;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<string>> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct)
|
||||
private IReadOnlyList<string> ListExpiredArchiveCandidates(DateTimeOffset archivedBeforeUtc)
|
||||
{
|
||||
IReadOnlyList<string> result = _cards
|
||||
return _cards
|
||||
.Where(card => card.Col == KanbanColumns.Archive
|
||||
&& _archivedAtById.TryGetValue(card.Id, out DateTimeOffset archivedAt)
|
||||
&& archivedAt < archivedBeforeUtc)
|
||||
.Select(card => card.Id)
|
||||
.ToList();
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<string>> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
|
||||
private IReadOnlyList<string> ListTrashCandidates(DateTimeOffset receivedBeforeUtc)
|
||||
{
|
||||
IReadOnlyList<string> result = _cards
|
||||
return _cards
|
||||
.Where(card => card.Col == KanbanColumns.Trash && ReceivedAtOf(card) < receivedBeforeUtc)
|
||||
.Select(card => card.Id)
|
||||
.ToList();
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> PurgeAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
|
||||
private int Purge(IReadOnlyList<string> cardIds)
|
||||
{
|
||||
// Жёсткое удаление пачки (как KanbanStore.PurgeAsync): комментарии — часть карточки, журнал не трогаем.
|
||||
if (cardIds.Count == 0)
|
||||
{
|
||||
return Task.FromResult(0);
|
||||
return 0;
|
||||
}
|
||||
|
||||
var ids = new HashSet<string>(cardIds, StringComparer.Ordinal);
|
||||
@@ -516,88 +570,45 @@ public class FakeKanjStore : ICardStore
|
||||
_archivedAtById.Remove(cardId);
|
||||
}
|
||||
|
||||
return Task.FromResult(removed);
|
||||
return removed;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Задаёт метку архивации карточки
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки.</param>
|
||||
/// <param name="archivedAt">Метка архивации (когда карточка ушла в архив).</param>
|
||||
public void SetArchivedAt(string cardId, DateTimeOffset archivedAt) => _archivedAtById[cardId] = archivedAt;
|
||||
|
||||
/// <summary>
|
||||
/// Метка архивации карточки — проверка archived_at после автоархива тика.
|
||||
/// </summary>
|
||||
/// <param name="cardId">Id карточки.</param>
|
||||
/// <returns>Метка архивации либо null — карточки нет/не архивирована/метка сброшена.</returns>
|
||||
public DateTimeOffset? ArchivedAtOf(string cardId)
|
||||
private IReadOnlyList<CardDto> ListCardsForConversion()
|
||||
{
|
||||
return _archivedAtById.TryGetValue(cardId, out DateTimeOffset archivedAt) ? archivedAt : null;
|
||||
}
|
||||
|
||||
// Момент получения карточки из её epoch-ms (как маппинг адаптера ReceivedAt → ReceivedAtMs).
|
||||
// card: Карточка.
|
||||
// Возвращает: ReceivedAt карточки как UTC-момент.
|
||||
private static DateTimeOffset ReceivedAtOf(CardDto card) => DateTimeOffset.FromUnixTimeMilliseconds(card.ReceivedAtMs);
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<CardDto>> ListCardsForConversionAsync(CancellationToken ct)
|
||||
{
|
||||
IReadOnlyList<CardDto> result = _cards
|
||||
return _cards
|
||||
.Where(card => card.Budget is not null
|
||||
&& card.Col != KanbanColumns.Archive
|
||||
&& card.Col != KanbanColumns.Trash)
|
||||
.OrderByDescending(card => card.ReceivedAtMs)
|
||||
.ToList();
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task UpdateConversionAsync(
|
||||
string cardId,
|
||||
double? convFrom,
|
||||
double? convTo,
|
||||
string convCur,
|
||||
CancellationToken ct)
|
||||
private void UpdateConversion(string cardId, double? convFrom, double? convTo, string convCur)
|
||||
{
|
||||
int index = _cards.FindIndex(card => card.Id == cardId);
|
||||
if (index >= 0)
|
||||
{
|
||||
_cards[index] = _cards[index] with
|
||||
{
|
||||
Converted = convCur.Length == 0
|
||||
? null
|
||||
: new CardBudgetDto(convFrom, convTo, convCur),
|
||||
Converted = convCur.Length == 0 ? null : new CardBudgetDto(convFrom, convTo, convCur),
|
||||
};
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<CardDto>> ListSelectedCardsAsync(string? containerId, CancellationToken ct)
|
||||
private IReadOnlyList<CardDto> ListSelectedCards(string? containerId)
|
||||
{
|
||||
IEnumerable<CardDto> query = containerId is null
|
||||
? _cards.Where(card => CardsDefaultContainers.Contains(card.Col))
|
||||
: _cards.Where(card => card.Col == containerId);
|
||||
return Task.FromResult<IReadOnlyList<CardDto>>(
|
||||
query.OrderByDescending(card => card.UpdatedAtMs).ToList());
|
||||
return query.OrderByDescending(card => card.UpdatedAtMs).ToList();
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> PatchCardAsync(
|
||||
string cardId,
|
||||
CardPatch patch,
|
||||
CancellationToken ct)
|
||||
private bool PatchCard(string cardId, CardPatch patch)
|
||||
{
|
||||
int index = _cards.FindIndex(card => card.Id == cardId);
|
||||
if (index < 0)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
CardDto card = _cards[index];
|
||||
@@ -647,19 +658,15 @@ public class FakeKanjStore : ICardStore
|
||||
}
|
||||
|
||||
_cards[index] = card with { UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() };
|
||||
return Task.FromResult(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> AddLinkAsync(
|
||||
string cardId,
|
||||
CardLinkDto link,
|
||||
CancellationToken ct)
|
||||
private bool AddLink(string cardId, CardLinkDto link)
|
||||
{
|
||||
int index = _cards.FindIndex(card => card.Id == cardId);
|
||||
if (index < 0)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
_cards[index] = _cards[index] with
|
||||
@@ -667,19 +674,15 @@ public class FakeKanjStore : ICardStore
|
||||
Links = [.. _cards[index].Links, link],
|
||||
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
};
|
||||
return Task.FromResult(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> RemoveLinkAsync(
|
||||
string cardId,
|
||||
string linkId,
|
||||
CancellationToken ct)
|
||||
private bool RemoveLink(string cardId, string linkId)
|
||||
{
|
||||
int index = _cards.FindIndex(card => card.Id == cardId);
|
||||
if (index < 0)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
_cards[index] = _cards[index] with
|
||||
@@ -687,19 +690,15 @@ public class FakeKanjStore : ICardStore
|
||||
Links = _cards[index].Links.Where(link => link.Id != linkId).ToList(),
|
||||
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
};
|
||||
return Task.FromResult(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> AddFileAsync(
|
||||
string cardId,
|
||||
CardFileDto file,
|
||||
CancellationToken ct)
|
||||
private bool AddFile(string cardId, CardFileDto file)
|
||||
{
|
||||
int index = _cards.FindIndex(card => card.Id == cardId);
|
||||
if (index < 0)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
_cards[index] = _cards[index] with
|
||||
@@ -707,19 +706,15 @@ public class FakeKanjStore : ICardStore
|
||||
Files = [.. _cards[index].Files, file],
|
||||
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
};
|
||||
return Task.FromResult(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> RemoveFileAsync(
|
||||
string cardId,
|
||||
string fileId,
|
||||
CancellationToken ct)
|
||||
private bool RemoveFile(string cardId, string fileId)
|
||||
{
|
||||
int index = _cards.FindIndex(card => card.Id == cardId);
|
||||
if (index < 0)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
_cards[index] = _cards[index] with
|
||||
@@ -727,21 +722,15 @@ public class FakeKanjStore : ICardStore
|
||||
Files = _cards[index].Files.Where(file => file.Id != fileId).ToList(),
|
||||
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
};
|
||||
return Task.FromResult(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<bool> MoveCardStageAsync(
|
||||
string cardId,
|
||||
string containerId,
|
||||
CardHistoryDto historyEntry,
|
||||
long atMs,
|
||||
CancellationToken ct)
|
||||
private bool MoveCardStage(string cardId, string containerId, CardHistoryDto historyEntry, long atMs)
|
||||
{
|
||||
int index = _cards.FindIndex(card => card.Id == cardId);
|
||||
if (index < 0)
|
||||
{
|
||||
return Task.FromResult(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
CardDto card = _cards[index];
|
||||
@@ -753,19 +742,15 @@ public class FakeKanjStore : ICardStore
|
||||
UpdatedAtMs = atMs,
|
||||
History = [.. card.History, historyEntry],
|
||||
};
|
||||
return Task.FromResult(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task SetReminderAsync(
|
||||
string cardId,
|
||||
long atMs,
|
||||
CancellationToken ct)
|
||||
private void SetReminder(string cardId, long atMs)
|
||||
{
|
||||
int index = _cards.FindIndex(card => card.Id == cardId);
|
||||
if (index < 0)
|
||||
{
|
||||
return Task.CompletedTask;
|
||||
return;
|
||||
}
|
||||
|
||||
_firedById.Remove(cardId);
|
||||
@@ -774,11 +759,9 @@ public class FakeKanjStore : ICardStore
|
||||
Reminder = new CardReminderDto(atMs),
|
||||
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||
};
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task ClearReminderAsync(string cardId, CancellationToken ct)
|
||||
private void ClearReminder(string cardId)
|
||||
{
|
||||
int index = _cards.FindIndex(card => card.Id == cardId);
|
||||
if (index >= 0)
|
||||
@@ -787,20 +770,14 @@ public class FakeKanjStore : ICardStore
|
||||
}
|
||||
|
||||
_firedById.Remove(cardId);
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> ClearStageAsync(string containerId, CancellationToken ct)
|
||||
{
|
||||
return Task.FromResult(_cards.RemoveAll(card => card.Col == containerId));
|
||||
}
|
||||
private int ClearStage(string containerId) => _cards.RemoveAll(card => card.Col == containerId);
|
||||
|
||||
/// <inheritdoc />
|
||||
public virtual Task<IReadOnlyList<CardReminderDueDto>> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct)
|
||||
private IReadOnlyList<CardReminderDueDto> ListDueReminders(DateTimeOffset now)
|
||||
{
|
||||
long nowMs = now.ToUnixTimeMilliseconds();
|
||||
IReadOnlyList<CardReminderDueDto> due = _cards
|
||||
return _cards
|
||||
.Where(card => card.Col == CardsDefaultContainers.Hold
|
||||
&& card.Reminder is not null
|
||||
&& !_firedById.Contains(card.Id)
|
||||
@@ -808,11 +785,9 @@ public class FakeKanjStore : ICardStore
|
||||
.OrderBy(card => card.Reminder!.At)
|
||||
.Select(card => new CardReminderDueDto(card.Id, card.Title, card.Col))
|
||||
.ToList();
|
||||
return Task.FromResult(due);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task MarkRemindersFiredAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
|
||||
private void MarkRemindersFired(IReadOnlyList<string> cardIds)
|
||||
{
|
||||
foreach (string cardId in cardIds)
|
||||
{
|
||||
@@ -821,12 +796,9 @@ public class FakeKanjStore : ICardStore
|
||||
_firedById.Add(cardId);
|
||||
}
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<int> ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct)
|
||||
private int ClearExpiredReminders(DateTimeOffset now)
|
||||
{
|
||||
long nowMs = now.ToUnixTimeMilliseconds();
|
||||
int cleared = 0;
|
||||
@@ -840,16 +812,16 @@ public class FakeKanjStore : ICardStore
|
||||
}
|
||||
}
|
||||
|
||||
return Task.FromResult(cleared);
|
||||
return cleared;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<IReadOnlyList<CardDto>> ListInboxWithSourceAsync(CancellationToken ct)
|
||||
private IReadOnlyList<CardDto> ListInboxWithSource()
|
||||
{
|
||||
IReadOnlyList<CardDto> result = _cards
|
||||
return _cards
|
||||
.Where(card => card.Col == KanbanColumns.Inbox && (card.Content.Text ?? string.Empty) != string.Empty)
|
||||
.OrderByDescending(card => card.ReceivedAtMs)
|
||||
.ToList();
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
private static DateTimeOffset ReceivedAtOf(CardDto card) => DateTimeOffset.FromUnixTimeMilliseconds(card.ReceivedAtMs);
|
||||
}
|
||||
+28
-28
@@ -1,15 +1,22 @@
|
||||
using Deal.Contracts.Integrations.Abstractions;
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Deal.Tests.Unit.Contracts;
|
||||
namespace Deal.Tests.Unit.Support;
|
||||
|
||||
/// <summary>
|
||||
/// In-memory реализация <see cref="IMlClient"/> для unit-тестов CardsService и воркера pump.
|
||||
/// Подставка <see cref="IMlClient"/>: сервисы получают NSubstitute-подставку (<see cref="Client"/>),
|
||||
/// тесты управляют сценариями через свойства (как раньше — через фейк).
|
||||
/// </summary>
|
||||
public sealed class FakeMlClient : IMlClient
|
||||
public sealed class TestMlClient
|
||||
{
|
||||
private readonly List<(string Text, string Label, double Delta)> _pushed = [];
|
||||
|
||||
/// <summary>
|
||||
/// Подставка порта ML-клиента (создаётся в конструкторе).
|
||||
/// </summary>
|
||||
public IMlClient Client { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Ответ StatusAsync
|
||||
/// </summary>
|
||||
@@ -26,37 +33,30 @@ public sealed class FakeMlClient : IMlClient
|
||||
public int PredictCalls { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Обучающие сигналы, отправленные через <see cref="PushAsync"/>
|
||||
/// Обучающие сигналы, отправленные через <see cref="IMlClient.PushAsync"/>
|
||||
/// </summary>
|
||||
public IReadOnlyList<(string Text, string Label, double Delta)> Pushed => _pushed.ToList();
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MlStatusResponseDto> StatusAsync(CancellationToken ct) => Task.FromResult(Status);
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task PushAsync(
|
||||
string text,
|
||||
string label,
|
||||
double delta,
|
||||
CancellationToken ct)
|
||||
/// <summary>
|
||||
/// Создаёт подставку с поведением по умолчанию.
|
||||
/// </summary>
|
||||
public TestMlClient()
|
||||
{
|
||||
_pushed.Add((text, label, delta));
|
||||
return Task.CompletedTask;
|
||||
Client = Substitute.For<IMlClient>();
|
||||
Client.StatusAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(ci => Status);
|
||||
Client.When(c => c.PushAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<double>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => _pushed.Add((ci.ArgAt<string>(0), ci.ArgAt<string>(1), ci.ArgAt<double>(2))));
|
||||
Client.PredictAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns<MlPredictResultDto>(ci =>
|
||||
{
|
||||
PredictCalls++;
|
||||
return Predict ?? throw new NotSupportedException();
|
||||
});
|
||||
Client.ResetAsync(Arg.Any<CancellationToken>())
|
||||
.Returns<MlResetResultDto>(_ => throw new NotSupportedException());
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MlPredictResultDto> PredictAsync(string text, CancellationToken ct)
|
||||
{
|
||||
PredictCalls++;
|
||||
return Predict is null
|
||||
? throw new NotSupportedException()
|
||||
: Task.FromResult(Predict);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public Task<MlResetResultDto> ResetAsync(CancellationToken ct) =>
|
||||
throw new NotSupportedException();
|
||||
|
||||
// Статус по умолчанию: модель не готова, все счётчики 0 (форма MlStatusResponseDto).
|
||||
// Возвращает: Пустой статус (ml/ai/learning = 0), как у LocalMlClient до действий.
|
||||
private static MlStatusResponseDto DefaultStatus()
|
||||
@@ -0,0 +1,65 @@
|
||||
using Deal.Modules.Kanban.Application.Abstractions;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Deal.Tests.Unit.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Подставка <see cref="IMlLearningStore"/> на списках: сервисы получают NSubstitute-подставку
|
||||
/// (<see cref="Store"/>), тесты сеют/проверяют очередь через <see cref="SeedOutbox"/> и <see cref="AddedRows"/>.
|
||||
/// </summary>
|
||||
public sealed class TestMlLearningStore
|
||||
{
|
||||
private readonly List<(string Id, string Text, string Label, double Delta)> _rows = [];
|
||||
|
||||
/// <summary>
|
||||
/// Подставка порта ML-обучения (создаётся в конструкторе).
|
||||
/// </summary>
|
||||
public IMlLearningStore Store { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Число записей журнала обучения
|
||||
/// </summary>
|
||||
public int LearningCount { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Строки очереди
|
||||
/// </summary>
|
||||
public IReadOnlyList<(string Id, string Text, string Label, double Delta)> AddedRows => _rows.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт подставку с пустой очередью.
|
||||
/// </summary>
|
||||
public TestMlLearningStore()
|
||||
{
|
||||
Store = Substitute.For<IMlLearningStore>();
|
||||
Store.CountLearningAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(ci => LearningCount);
|
||||
Store.CountOutboxAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(_ => _rows.Count);
|
||||
Store.When(s => s.AddOutboxAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<double>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => _rows.Add((ci.ArgAt<string>(0), ci.ArgAt<string>(1), ci.ArgAt<string>(2), ci.ArgAt<double>(3))));
|
||||
Store.When(s => s.ClearOutboxAsync(Arg.Any<CancellationToken>()))
|
||||
.Do(_ => _rows.Clear());
|
||||
Store.TakeOutboxBatchAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => _rows
|
||||
.Take(ci.ArgAt<int>(0))
|
||||
.Select(row => new MlOutboxEntryDto(row.Id, row.Text, row.Label, row.Delta))
|
||||
.ToList());
|
||||
Store.When(s => s.DeleteOutboxAsync(Arg.Any<IReadOnlyCollection<string>>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => _rows.RemoveAll(row => ci.Arg<IReadOnlyCollection<string>>().Contains(row.Id)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Пишет строку очереди напрямую
|
||||
/// </summary>
|
||||
/// <param name="id">Id строки (<c>mle_...</c>).</param>
|
||||
/// <param name="text">Текст обучающего примера.</param>
|
||||
/// <param name="label">Метка обучения.</param>
|
||||
/// <param name="delta">Вес сигнала.</param>
|
||||
public void SeedOutbox(string id, string text, string label, double delta = 1.0)
|
||||
{
|
||||
_rows.Add((id, text, label, delta));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
using Deal.Modules.Tenants.Application.Abstractions;
|
||||
using Deal.Modules.Tenants.Application.Models;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Deal.Tests.Unit.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Подставка <see cref="IOperatorAuthStore"/> на списках: сервисы получают NSubstitute-подставку
|
||||
/// (<see cref="Store"/>), тесты сеют/проверяют операторов и сессии через
|
||||
/// <see cref="AddOperator"/>, <see cref="AddSession"/>, <see cref="Operators"/>, <see cref="Sessions"/>,
|
||||
/// журнал мутирующих вызовов — <see cref="Calls"/>.
|
||||
/// </summary>
|
||||
public sealed class TestOperatorAuthStore
|
||||
{
|
||||
private readonly List<StoredOperatorDto> _operators = [];
|
||||
private readonly List<OperatorSessionDto> _sessions = [];
|
||||
private readonly List<string> _calls = [];
|
||||
|
||||
/// <summary>
|
||||
/// Подставка порта операторской аутентификации (создаётся в конструкторе).
|
||||
/// </summary>
|
||||
public IOperatorAuthStore Store { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Операторы хранилища.
|
||||
/// </summary>
|
||||
public IReadOnlyList<StoredOperatorDto> Operators => _operators;
|
||||
|
||||
/// <summary>
|
||||
/// Сессии операторов хранилища.
|
||||
/// </summary>
|
||||
public IReadOnlyList<OperatorSessionDto> Sessions => _sessions;
|
||||
|
||||
/// <summary>
|
||||
/// Журнал мутирующих вызовов в порядке их совершения.
|
||||
/// </summary>
|
||||
public IReadOnlyList<string> Calls => _calls;
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт подставку с пустыми списками.
|
||||
/// </summary>
|
||||
public TestOperatorAuthStore()
|
||||
{
|
||||
Store = Substitute.For<IOperatorAuthStore>();
|
||||
Store.FindByLoginAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => _operators.SingleOrDefault(o => o.Login == ci.ArgAt<string>(0)));
|
||||
Store.FindSessionByTokenHashAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => _sessions.SingleOrDefault(s => s.TokenHash == ci.ArgAt<string>(0)));
|
||||
Store.When(s => s.CreateAsync(Arg.Any<StoredOperatorDto>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci =>
|
||||
{
|
||||
StoredOperatorDto operatorRecord = ci.Arg<StoredOperatorDto>();
|
||||
_operators.Add(operatorRecord);
|
||||
_calls.Add($"create-operator:{operatorRecord.Id}");
|
||||
});
|
||||
Store.When(s => s.CreateSessionAsync(Arg.Any<OperatorSessionDto>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci =>
|
||||
{
|
||||
OperatorSessionDto session = ci.Arg<OperatorSessionDto>();
|
||||
_sessions.Add(session);
|
||||
_calls.Add($"create-session:{session.OperatorId}");
|
||||
});
|
||||
Store.When(s => s.DeleteSessionAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci =>
|
||||
{
|
||||
_sessions.RemoveAll(s => s.TokenHash == ci.ArgAt<string>(0));
|
||||
_calls.Add($"delete-session:{ci.ArgAt<string>(0)}");
|
||||
});
|
||||
Store.When(s => s.DeleteExpiredSessionsAsync(Arg.Any<CancellationToken>()))
|
||||
.Do(_ =>
|
||||
{
|
||||
_sessions.RemoveAll(s => s.ExpiresAt <= DateTimeOffset.UtcNow);
|
||||
_calls.Add("delete-expired");
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Добавляет оператора
|
||||
/// </summary>
|
||||
/// <param name="operatorRecord">Оператор.</param>
|
||||
public void AddOperator(StoredOperatorDto operatorRecord) => _operators.Add(operatorRecord);
|
||||
|
||||
/// <summary>
|
||||
/// Добавляет сессию
|
||||
/// </summary>
|
||||
/// <param name="session">Сессия.</param>
|
||||
public void AddSession(OperatorSessionDto session) => _sessions.Add(session);
|
||||
}
|
||||
@@ -0,0 +1,293 @@
|
||||
using Deal.Modules.Cards.Application.Sources;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Pipeline.Application.Abstractions;
|
||||
using Deal.Modules.Pipeline.Application.Models;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Deal.Tests.Unit.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Подставка <see cref="IPipelineStore"/> на списках: сервисы получают NSubstitute-подставку
|
||||
/// (<see cref="Store"/>), тесты сеют/проверяют очередь и отсеянных через <see cref="Queue"/>,
|
||||
/// <see cref="Rejected"/>, <see cref="SeedQueue"/>, <see cref="SeedRejected"/>.
|
||||
/// </summary>
|
||||
public sealed class TestPipelineStore
|
||||
{
|
||||
private readonly List<QueueItemDto> _queue = [];
|
||||
private readonly List<RejectedItemDto> _rejected = [];
|
||||
private readonly Dictionary<string, string?> _dedup = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Подставка порта пайплайна (создаётся в конструкторе).
|
||||
/// </summary>
|
||||
public IPipelineStore Store { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Строки очереди подставки
|
||||
/// </summary>
|
||||
public IReadOnlyList<QueueItemDto> Queue => _queue.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Записи отсева подставки
|
||||
/// </summary>
|
||||
public IReadOnlyList<RejectedItemDto> Rejected => _rejected.ToList();
|
||||
|
||||
private readonly bool _claimRace;
|
||||
private readonly bool _claimResult;
|
||||
private readonly bool _throwOnList;
|
||||
|
||||
/// <summary>
|
||||
/// Сколько раз вызван ClaimAsync
|
||||
/// </summary>
|
||||
public int ClaimCalls { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Сколько раз вызван DeleteClaimAsync
|
||||
/// </summary>
|
||||
public int DeleteClaimCalls { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт подставку с пустыми списками.
|
||||
/// </summary>
|
||||
/// <param name="claimRace">Сценарий claim-гонки: ExistsAsync=false, ClaimAsync возвращает claimResult.</param>
|
||||
/// <param name="claimResult">Результат ClaimAsync в сценарии гонки (false — хэш уже заявлен другим проходом).</param>
|
||||
/// <param name="throwOnList">Сценарий сбоя чтения очереди: ListAsync бросает (имитация недоступной схемы/БД).</param>
|
||||
public TestPipelineStore(
|
||||
bool claimRace = false,
|
||||
bool claimResult = false,
|
||||
bool throwOnList = false)
|
||||
{
|
||||
_claimRace = claimRace;
|
||||
_claimResult = claimResult;
|
||||
_throwOnList = throwOnList;
|
||||
Store = Substitute.For<IPipelineStore>();
|
||||
Store.ExistsDuplicateAsync(Arg.Any<SourceRef>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => ExistsDuplicate(ci.Arg<SourceRef>()));
|
||||
Store.When(s => s.AddAsync(Arg.Any<QueueItemDto>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => _queue.Add(ci.Arg<QueueItemDto>()));
|
||||
if (_throwOnList)
|
||||
{
|
||||
Store.ListAsync(Arg.Any<string?>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns<Task<IReadOnlyList<QueueItemDto>>>(_ =>
|
||||
throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync)."));
|
||||
}
|
||||
else
|
||||
{
|
||||
Store.ListAsync(Arg.Any<string?>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => List(ci.ArgAt<string?>(0), ci.ArgAt<int>(1)));
|
||||
}
|
||||
Store.CountByStatusAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => CountByStatus(ci.ArgAt<string>(0)));
|
||||
Store.When(s => s.SetStatusAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => SetStatus(ci.ArgAt<string>(0), ci.ArgAt<string>(1)));
|
||||
Store.When(s => s.RemoveAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => Remove(ci.ArgAt<string>(0)));
|
||||
Store.When(s => s.UpsertAsync(Arg.Any<RejectRecord>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => Upsert(ci.Arg<RejectRecord>()));
|
||||
Store.ListPageAsync(Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => ListPage(ci.ArgAt<int>(0), ci.ArgAt<int>(1)));
|
||||
Store.SearchAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => Search(ci.ArgAt<string>(0), ci.ArgAt<int>(2)));
|
||||
Store.CountAsync(Arg.Any<CancellationToken>()).Returns(_ => _rejected.Count);
|
||||
Store.GetAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => _rejected.FirstOrDefault(item => item.Id == ci.ArgAt<string>(0)));
|
||||
Store.When(s => s.DeleteAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => _rejected.RemoveAll(item => item.Id == ci.ArgAt<string>(0)));
|
||||
Store.ClearAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(ci =>
|
||||
{
|
||||
int cleared = _rejected.Count;
|
||||
_rejected.Clear();
|
||||
return cleared;
|
||||
});
|
||||
Store.PurgeExpiredAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => PurgeExpired(ci.ArgAt<DateTimeOffset>(0)));
|
||||
Store.When(s => s.MarkReturnedAsync(
|
||||
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => MarkReturned(ci.ArgAt<string>(0), ci.ArgAt<string>(1), ci.ArgAt<DateTimeOffset>(2)));
|
||||
Store.When(s => s.LinkAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => _dedup[ci.ArgAt<string>(0)] = ci.ArgAt<string>(1));
|
||||
Store.When(s => s.DeleteByCardAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => DeleteByCard(ci.ArgAt<string>(0)));
|
||||
Store.ExistsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => !_claimRace && _dedup.ContainsKey(ci.ArgAt<string>(0)));
|
||||
Store.When(s => s.ClaimAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
|
||||
.Do(_ => ClaimCalls++);
|
||||
Store.ClaimAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => _claimRace
|
||||
? _claimResult
|
||||
: _dedup.TryAdd(ci.ArgAt<string>(0), null));
|
||||
Store.When(s => s.DeleteClaimAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
|
||||
.Do(_ => DeleteClaimCalls++);
|
||||
Store.When(s => s.DeleteClaimAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci =>
|
||||
{
|
||||
if (!_claimRace && _dedup.TryGetValue(ci.ArgAt<string>(0), out string? leadId) && leadId is null)
|
||||
{
|
||||
_dedup.Remove(ci.ArgAt<string>(0));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Кладёт строку очереди напрямую
|
||||
/// </summary>
|
||||
/// <param name="item">Строка как если бы была сохранена в БД.</param>
|
||||
public void SeedQueue(QueueItemDto item)
|
||||
{
|
||||
_queue.Add(item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Кладёт запись отсева напрямую
|
||||
/// </summary>
|
||||
/// <param name="item">Запись как если бы была сохранена в БД.</param>
|
||||
public void SeedRejected(RejectedItemDto item)
|
||||
{
|
||||
_rejected.Add(item);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Id карточки, связанной с хэшем дедупа
|
||||
/// </summary>
|
||||
/// <param name="hash">SHA1-hex нормализованного текста.</param>
|
||||
public string? DedupLeadId(string hash)
|
||||
{
|
||||
return _dedup.TryGetValue(hash, out string? leadId) ? leadId : null;
|
||||
}
|
||||
|
||||
private bool ExistsDuplicate(SourceRef source)
|
||||
{
|
||||
string key = source.DedupeKey();
|
||||
return _queue.Any(item => item.Source.DedupeKey() == key);
|
||||
}
|
||||
|
||||
private IReadOnlyList<QueueItemDto> List(string? status, int limit)
|
||||
{
|
||||
return _queue
|
||||
.Where(item => status is null || item.Status == status)
|
||||
.OrderBy(item => item.QueuedAtMs)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private int CountByStatus(string status) => _queue.Count(item => item.Status == status);
|
||||
|
||||
private void SetStatus(string id, string status)
|
||||
{
|
||||
int index = _queue.FindIndex(item => item.Id == id);
|
||||
if (index >= 0)
|
||||
{
|
||||
_queue[index] = _queue[index] with { Status = status };
|
||||
}
|
||||
}
|
||||
|
||||
private void Remove(string id) => _queue.RemoveAll(item => item.Id == id);
|
||||
|
||||
private void Upsert(RejectRecord record)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(record.Text))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
string id = record.DeterministicId ?? PrefixId.New(PipelineIdPrefixes.Rejected);
|
||||
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||
|
||||
int index = _rejected.FindIndex(item => item.Id == id);
|
||||
RejectedItemDto row = ToItem(record, id, nowMs);
|
||||
if (index >= 0)
|
||||
{
|
||||
RejectedItemDto existing = _rejected[index];
|
||||
row = row with
|
||||
{
|
||||
Returned = existing.Returned,
|
||||
ReturnedAtMs = existing.ReturnedAtMs,
|
||||
ReturnReason = existing.ReturnReason,
|
||||
};
|
||||
_rejected[index] = row;
|
||||
}
|
||||
else
|
||||
{
|
||||
_rejected.Add(row);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<RejectedItemDto> ListPage(int offset, int limit)
|
||||
{
|
||||
return _rejected
|
||||
.OrderByDescending(item => item.RejectedAtMs)
|
||||
.Skip(offset)
|
||||
.Take(limit)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private IReadOnlyList<RejectedItemDto> Search(string q, int limitLike)
|
||||
{
|
||||
string query = q.Trim();
|
||||
return _rejected
|
||||
.Where(item => LikeField(item.Text, query)
|
||||
|| LikeField(item.Reason, query)
|
||||
|| LikeField(item.Kw, query)
|
||||
|| LikeField(item.Source.DisplayName ?? string.Empty, query))
|
||||
.OrderByDescending(item => item.RejectedAtMs)
|
||||
.Take(limitLike)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private int PurgeExpired(DateTimeOffset olderThan)
|
||||
{
|
||||
long cutMs = olderThan.ToUnixTimeMilliseconds();
|
||||
return _rejected.RemoveAll(item => item.RejectedAtMs < cutMs);
|
||||
}
|
||||
|
||||
private void MarkReturned(string id, string reason, DateTimeOffset returnedAt)
|
||||
{
|
||||
int index = _rejected.FindIndex(item => item.Id == id);
|
||||
if (index >= 0)
|
||||
{
|
||||
_rejected[index] = _rejected[index] with
|
||||
{
|
||||
Returned = true,
|
||||
ReturnedAtMs = returnedAt.ToUnixTimeMilliseconds(),
|
||||
ReturnReason = reason,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
private void DeleteByCard(string cardId)
|
||||
{
|
||||
foreach (string hash in _dedup.Where(pair => pair.Value == cardId).Select(pair => pair.Key).ToList())
|
||||
{
|
||||
_dedup.Remove(hash);
|
||||
}
|
||||
}
|
||||
|
||||
// Подстрока q в lower(поле) — LIKE-половина адаптера (поле колонки SQL приводится к нижнему регистру).
|
||||
// field: Поле записи отсева (text/reason/kw/ch_name).
|
||||
// query: Поисковый запрос, нормализованный сервисом (trim+lowercase).
|
||||
// Возвращает: True — поле содержит запрос без учёта регистра поля.
|
||||
private static bool LikeField(string field, string query)
|
||||
{
|
||||
return field.ToLowerInvariant().Contains(query, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static RejectedItemDto ToItem(
|
||||
RejectRecord record,
|
||||
string id,
|
||||
long rejectedAtMs) => new()
|
||||
{
|
||||
Id = id,
|
||||
Source = record.Source,
|
||||
Content = record.Content,
|
||||
Text = record.Text,
|
||||
Stage = record.Stage,
|
||||
StageLabel = PipelineRejectConstants.StageLabel(record.Stage),
|
||||
Reason = record.Reason,
|
||||
Kw = record.Kw,
|
||||
DecidedBy = record.DecidedBy,
|
||||
DecidedByLabel = PipelineRejectConstants.SourceLabel(record.DecidedBy),
|
||||
MsgAtMs = record.MsgAtMs,
|
||||
RejectedAtMs = rejectedAtMs,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
using Deal.Modules.Settings.Application.Abstractions;
|
||||
using Deal.Modules.Settings.Application.Models;
|
||||
using NSubstitute;
|
||||
|
||||
namespace Deal.Tests.Unit.Support;
|
||||
|
||||
/// <summary>
|
||||
/// Подставка <see cref="ISettingsStore"/> на словаре: сервисы получают NSubstitute-подставку
|
||||
/// (<see cref="Store"/>), тесты сеют/проверяют настройки через <see cref="Preload"/>,
|
||||
/// <see cref="GetStoredJson"/> и <see cref="Keys"/>.
|
||||
/// </summary>
|
||||
public sealed class TestSettingsStore
|
||||
{
|
||||
private readonly Dictionary<string, SettingValue> _rows = new(StringComparer.Ordinal);
|
||||
|
||||
/// <summary>
|
||||
/// Подставка порта настроек (создаётся в конструкторе).
|
||||
/// </summary>
|
||||
public ISettingsStore Store { get; }
|
||||
|
||||
/// <summary>
|
||||
/// Ключи сохранённых настроек.
|
||||
/// </summary>
|
||||
public IReadOnlyCollection<string> Keys => _rows.Keys.ToList();
|
||||
|
||||
/// <summary>
|
||||
/// Создаёт подставку с пустым словарём.
|
||||
/// </summary>
|
||||
public TestSettingsStore()
|
||||
{
|
||||
Store = Substitute.For<ISettingsStore>();
|
||||
Store.GetAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||
.Returns(ci => _rows.TryGetValue(ci.ArgAt<string>(0), out SettingValue? row) ? row : null);
|
||||
Store.GetAllAsync(Arg.Any<CancellationToken>())
|
||||
.Returns(ci => _rows.Values.ToList());
|
||||
Store.When(s => s.SetAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => _rows[ci.ArgAt<string>(0)] =
|
||||
new SettingValue(ci.ArgAt<string>(0), ci.ArgAt<string>(1), DateTimeOffset.UtcNow));
|
||||
Store.When(s => s.RemoveAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
|
||||
.Do(ci => _rows.Remove(ci.ArgAt<string>(0)));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Кладёт готовую строку
|
||||
/// </summary>
|
||||
/// <param name="key">Ключ настройки.</param>
|
||||
/// <param name="valueJson">Значение, сериализованное в JSON.</param>
|
||||
public void Preload(string key, string valueJson)
|
||||
{
|
||||
_rows[key] = new SettingValue(key, valueJson, DateTimeOffset.UtcNow);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Возвращает JSON сохранённого значения или null, если ключа нет.
|
||||
/// </summary>
|
||||
/// <param name="key">Ключ настройки.</param>
|
||||
/// <returns>JSON-строка значения или null.</returns>
|
||||
public string? GetStoredJson(string key)
|
||||
{
|
||||
return _rows.TryGetValue(key, out SettingValue? row) ? row.ValueJson : null;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ namespace Deal.Tests.Unit.Support;
|
||||
/// </summary>
|
||||
public sealed class TokenUsageRecorderTests
|
||||
{
|
||||
// Id тенанта сценариев строкой (формат N) — как в GrpcAi* тестах; Guid для FakeTenantLimitStore.
|
||||
// Id тенанта сценариев строкой (формат N) — как в GrpcAi* тестах; Guid для TestTenantLimitStore.
|
||||
private const string TenantIdValue = "0123456789abcdef0123456789abcdef";
|
||||
|
||||
private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue);
|
||||
@@ -26,7 +26,7 @@ public sealed class TokenUsageRecorderTests
|
||||
[Fact]
|
||||
public async Task AddAsync_ChargesTenantLimitsAccumulatesLifetimeKvAndWritesEvent()
|
||||
{
|
||||
(FakeSettingsStore settings, FakeTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
|
||||
(TestSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
|
||||
|
||||
await recorder.AddAsync(new Usage { Prompt = 500, Completion = 40, Total = 540 }, "deepseek", "deepseek-chat", CancellationToken.None);
|
||||
|
||||
@@ -52,7 +52,7 @@ public sealed class TokenUsageRecorderTests
|
||||
[Fact]
|
||||
public async Task AddAsync_AccumulatesAcrossCalls()
|
||||
{
|
||||
(FakeSettingsStore settings, FakeTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
|
||||
(TestSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
|
||||
|
||||
await recorder.AddAsync(new Usage { Prompt = 500, Completion = 40, Total = 540 }, "deepseek", "m", CancellationToken.None);
|
||||
await recorder.AddAsync(new Usage { Prompt = 300, Completion = 60, Total = 360 }, "deepseek", "m", CancellationToken.None);
|
||||
@@ -68,7 +68,7 @@ public sealed class TokenUsageRecorderTests
|
||||
[Fact]
|
||||
public async Task AddAsync_NullUsage_IsNoop()
|
||||
{
|
||||
(FakeSettingsStore settings, FakeTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
|
||||
(TestSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
|
||||
|
||||
await recorder.AddAsync(null, "deepseek", "m", CancellationToken.None);
|
||||
|
||||
@@ -80,7 +80,7 @@ public sealed class TokenUsageRecorderTests
|
||||
[Fact]
|
||||
public async Task AddAsync_ZeroTotal_AccumulatesKvAndWritesEventButSkipsTenantLimits()
|
||||
{
|
||||
(FakeSettingsStore settings, FakeTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
|
||||
(TestSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
|
||||
|
||||
// Ответ модели без оценки (total=0): lifetime-KV и событие пишутся (вызов состоялся), строка лимита
|
||||
// нулевым расходом не заводится — ленивый GetOrCreate остаётся первому реальному списанию/чтению.
|
||||
@@ -96,11 +96,11 @@ public sealed class TokenUsageRecorderTests
|
||||
[Fact]
|
||||
public async Task AddAsync_OutsideTenantContext_Throws()
|
||||
{
|
||||
var settings = new FakeSettingsStore();
|
||||
var limits = new FakeTenantLimitStore();
|
||||
var settings = new TestSettingsStore();
|
||||
var limits = new TestTenantLimitStore();
|
||||
ITenantContext tenantContext = new TenantContext(); // без SetTenant — списание вне tenant-контекста невозможно.
|
||||
var recorder = new TokenUsageRecorder(
|
||||
settings, limits, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store));
|
||||
settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store));
|
||||
|
||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||
() => recorder.AddAsync(new Usage { Prompt = 1, Completion = 1, Total = 2 }, "deepseek", "m", CancellationToken.None));
|
||||
@@ -111,7 +111,7 @@ public sealed class TokenUsageRecorderTests
|
||||
[Fact]
|
||||
public async Task AddEstimatedAsync_MlCall_WritesMlEventOnlyWithCharEstimate()
|
||||
{
|
||||
(FakeSettingsStore settings, FakeTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
|
||||
(TestSettingsStore settings, TestTenantLimitStore limits, TestTokenUsageEventStore events, TokenUsageRecorder recorder) = CreateRecorder();
|
||||
|
||||
// 20 символов → 5 токенов (≈chars/4); бюджет и lifetime AI-счётчик не трогаются.
|
||||
long estimated = await recorder.AddEstimatedAsync(
|
||||
@@ -141,14 +141,14 @@ public sealed class TokenUsageRecorderTests
|
||||
|
||||
// Создаёт recorder сценария: tenant-контекст + фейки KV/лимитов/истории.
|
||||
// Возвращает: Кортеж (настройки, лимиты, история, recorder).
|
||||
private static (FakeSettingsStore Settings, FakeTenantLimitStore Limits, TestTokenUsageEventStore Events, TokenUsageRecorder Recorder) CreateRecorder()
|
||||
private static (TestSettingsStore Settings, TestTenantLimitStore Limits, TestTokenUsageEventStore Events, TokenUsageRecorder Recorder) CreateRecorder()
|
||||
{
|
||||
var settings = new FakeSettingsStore();
|
||||
var limits = new FakeTenantLimitStore();
|
||||
var settings = new TestSettingsStore();
|
||||
var limits = new TestTenantLimitStore();
|
||||
var events = new TestTokenUsageEventStore();
|
||||
ITenantContext tenantContext = new TenantContext();
|
||||
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
||||
var recorder = new TokenUsageRecorder(settings, limits, tenantContext, new TokenUsageEventService(events.Store));
|
||||
var recorder = new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(events.Store));
|
||||
return (settings, limits, events, recorder);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user