Перевести тестовые фейки на моки NSubstitute #13
@@ -1,10 +1,27 @@
|
|||||||
name: ci
|
name: ci
|
||||||
|
|
||||||
|
# Прогоняем CI в двух случаях:
|
||||||
|
# 1) МР: создан, переоткрыт или в ветку МР запушены новые коммиты (synchronize).
|
||||||
|
# 2) Влитие в main (пуш в main — в т.ч. мерж МР).
|
||||||
|
# Обычные коммиты в ветки без открытого МР CI не запускают.
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
pull_request:
|
pull_request:
|
||||||
|
types:
|
||||||
|
- opened
|
||||||
|
- synchronize
|
||||||
|
- reopened
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
|
# Новый прогон того же рефа (та же ветка МР / тот же main) отменяет незавершённый предыдущий:
|
||||||
|
# коммит-А стартовал, через пару минут коммит-Б — прогон А отменяется, если ещё не закончил.
|
||||||
|
# Завершённые (успех/фейл) прогоны не трогаются.
|
||||||
|
concurrency:
|
||||||
|
group: ci-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
build-test:
|
build-test:
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public sealed class DiscoveryBanGuard
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="store">Хранилище Discovery (счётчик DiscLog за сутки).</param>
|
/// <param name="store">Хранилище Discovery (счётчик DiscLog за сутки).</param>
|
||||||
/// <param name="settings">KV-настройки тенанта (discJoinLimit/discFloodDay/discPaused).</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(
|
public DiscoveryBanGuard(
|
||||||
IDiscoveryStore store,
|
IDiscoveryStore store,
|
||||||
ISettingsStore settings,
|
ISettingsStore settings,
|
||||||
|
|||||||
@@ -24,11 +24,11 @@ public sealed class DataRetentionSchedulerTests
|
|||||||
public async Task RunCycle_PurgesAgedAuditResetsExpiredLimitsAndDeletesExpiredCounters()
|
public async Task RunCycle_PurgesAgedAuditResetsExpiredLimitsAndDeletesExpiredCounters()
|
||||||
{
|
{
|
||||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||||
var audit = new FakeAuditLogStore();
|
var audit = new TestAuditLogStore();
|
||||||
await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 20))), CancellationToken.None);
|
await audit.Store.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 20))), CancellationToken.None);
|
||||||
await audit.AppendAsync(AuditRecord(AuditEvents.TenantLoginOk, now.AddDays(-1)), 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);
|
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()
|
public async Task RunCycle_IsIdempotent()
|
||||||
{
|
{
|
||||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||||
var audit = new FakeAuditLogStore();
|
var audit = new TestAuditLogStore();
|
||||||
await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None);
|
await audit.Store.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None);
|
||||||
var limits = new FakeTenantLimitStore();
|
var limits = new TestTenantLimitStore();
|
||||||
limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 500);
|
limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 500);
|
||||||
await using ServiceProvider provider = BuildProvider(audit, limits, new TestRateLimitCounterStore());
|
await using ServiceProvider provider = BuildProvider(audit, limits, new TestRateLimitCounterStore());
|
||||||
DataRetentionScheduler scheduler = new(
|
DataRetentionScheduler scheduler = new(
|
||||||
@@ -86,13 +86,13 @@ public sealed class DataRetentionSchedulerTests
|
|||||||
// counters: Хелпер счётчиков (окна посеяны сценарием).
|
// counters: Хелпер счётчиков (окна посеяны сценарием).
|
||||||
// Возвращает: Провайдер с сервисами цикла.
|
// Возвращает: Провайдер с сервисами цикла.
|
||||||
private static ServiceProvider BuildProvider(
|
private static ServiceProvider BuildProvider(
|
||||||
FakeAuditLogStore audit,
|
TestAuditLogStore audit,
|
||||||
FakeTenantLimitStore limits,
|
TestTenantLimitStore limits,
|
||||||
TestRateLimitCounterStore counters)
|
TestRateLimitCounterStore counters)
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
services.AddScoped<IAuditLogStore>(_ => audit);
|
services.AddScoped<IAuditLogStore>(_ => audit.Store);
|
||||||
services.AddScoped<ITenantLimitStore>(_ => limits);
|
services.AddScoped<ITenantLimitStore>(_ => limits.Store);
|
||||||
services.AddScoped<IRateLimitCounterStore>(_ => counters.Store);
|
services.AddScoped<IRateLimitCounterStore>(_ => counters.Store);
|
||||||
return services.BuildServiceProvider();
|
return services.BuildServiceProvider();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -94,8 +94,8 @@ public sealed class DiscoveryWorkerSchedulerTests
|
|||||||
var tenantContext = new TenantContext();
|
var tenantContext = new TenantContext();
|
||||||
var storeA = new TestDiscoveryStore();
|
var storeA = new TestDiscoveryStore();
|
||||||
var storeB = new TestDiscoveryStore();
|
var storeB = new TestDiscoveryStore();
|
||||||
var settingsA = new FakeSettingsStore();
|
var settingsA = new TestSettingsStore();
|
||||||
var settingsB = new FakeSettingsStore();
|
var settingsB = new TestSettingsStore();
|
||||||
var gatewayA = new TestDiscoveryGateway();
|
var gatewayA = new TestDiscoveryGateway();
|
||||||
var gatewayB = new TestDiscoveryGateway();
|
var gatewayB = new TestDiscoveryGateway();
|
||||||
PreloadDiscoveryEvalOff(settingsA);
|
PreloadDiscoveryEvalOff(settingsA);
|
||||||
@@ -104,12 +104,12 @@ public sealed class DiscoveryWorkerSchedulerTests
|
|||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
services.AddSingleton<ITenantContext>(tenantContext);
|
services.AddSingleton<ITenantContext>(tenantContext);
|
||||||
services.AddSingleton<ITenantRepository>(tenants.Repository);
|
services.AddSingleton<ITenantRepository>(tenants.Repository);
|
||||||
services.AddSingleton<IMlClient>(new FakeMlClient());
|
services.AddSingleton<IMlClient>(new TestMlClient().Client);
|
||||||
services.AddSingleton<IAiTools>(_ => TestAiTools.New());
|
services.AddSingleton<IAiTools>(_ => TestAiTools.New());
|
||||||
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
|
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
|
||||||
// (эталон PipelineWorkerSchedulerTests/ConnectionStringProvider.ForTenant).
|
// (эталон PipelineWorkerSchedulerTests/ConnectionStringProvider.ForTenant).
|
||||||
services.AddScoped<IDiscoveryStore>(provider => TenantOf(provider) == TenantA ? storeA.Store : storeB.Store);
|
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);
|
services.AddScoped<ITelegramGateway>(provider => TenantOf(provider) == TenantA ? gatewayA.Gateway : gatewayB.Gateway);
|
||||||
// Реальные сервисы модуля Discovery — как AddDiscoveryModule в Program.cs: цикл резолвит их в tenant-scope.
|
// Реальные сервисы модуля Discovery — как AddDiscoveryModule в Program.cs: цикл резолвит их в tenant-scope.
|
||||||
services.AddDiscoveryModule();
|
services.AddDiscoveryModule();
|
||||||
@@ -124,7 +124,7 @@ public sealed class DiscoveryWorkerSchedulerTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ML/ИИ выключены в настройках тенанта (оценка содержания — эвристика по ключам).
|
// ML/ИИ выключены в настройках тенанта (оценка содержания — эвристика по ключам).
|
||||||
private static void PreloadDiscoveryEvalOff(FakeSettingsStore settings)
|
private static void PreloadDiscoveryEvalOff(TestSettingsStore settings)
|
||||||
{
|
{
|
||||||
settings.Preload(SettingsKeys.MlEnabled, "false");
|
settings.Preload(SettingsKeys.MlEnabled, "false");
|
||||||
settings.Preload(SettingsKeys.AiEnabled, "false");
|
settings.Preload(SettingsKeys.AiEnabled, "false");
|
||||||
|
|||||||
@@ -49,7 +49,7 @@ public sealed class IngressRateLimitInterceptorTests
|
|||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
registry,
|
registry,
|
||||||
services => services.AddScoped<IPipelineStore>(_ => new FakePipelineStore()),
|
services => services.AddScoped<IPipelineStore>(_ => new TestPipelineStore().Store),
|
||||||
async channel =>
|
async channel =>
|
||||||
{
|
{
|
||||||
PushSourceReply first = await PushAsync(channel, TenantA);
|
PushSourceReply first = await PushAsync(channel, TenantA);
|
||||||
@@ -77,7 +77,7 @@ public sealed class IngressRateLimitInterceptorTests
|
|||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
registry,
|
registry,
|
||||||
services => services.AddScoped<IPipelineStore>(_ => new FakePipelineStore()),
|
services => services.AddScoped<IPipelineStore>(_ => new TestPipelineStore().Store),
|
||||||
async channel =>
|
async channel =>
|
||||||
{
|
{
|
||||||
// Окно ингресса — 1/мин: единственный разрешённый вызов исчерпывает лимит.
|
// Окно ингресса — 1/мин: единственный разрешённый вызов исчерпывает лимит.
|
||||||
|
|||||||
@@ -39,14 +39,14 @@ public sealed class MlOutboxFlushSchedulerTests
|
|||||||
{
|
{
|
||||||
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
||||||
{
|
{
|
||||||
var store = new FakeMlLearningStore();
|
var store = new TestMlLearningStore();
|
||||||
SeedRows(store, count: 25, prefix: "a");
|
SeedRows(store, count: 25, prefix: "a");
|
||||||
var tenantContext = new TenantContext();
|
var tenantContext = new TenantContext();
|
||||||
await using ServiceProvider provider = BuildProvider(
|
await using ServiceProvider provider = BuildProvider(
|
||||||
port,
|
port,
|
||||||
new TestTenantRepository(Tenant(TenantA)),
|
new TestTenantRepository(Tenant(TenantA)),
|
||||||
tenantContext,
|
tenantContext,
|
||||||
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store });
|
new Dictionary<Guid, TestMlLearningStore> { [TenantA] = store });
|
||||||
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
|
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
|
||||||
|
|
||||||
await scheduler.RunCycleAsync(CancellationToken.None);
|
await scheduler.RunCycleAsync(CancellationToken.None);
|
||||||
@@ -54,7 +54,7 @@ public sealed class MlOutboxFlushSchedulerTests
|
|||||||
// 25 строк → 3 батча по 10/10/5 (ml_client.flush_outbox: chunk=10), строки удалены после успеха.
|
// 25 строк → 3 батча по 10/10/5 (ml_client.flush_outbox: chunk=10), строки удалены после успеха.
|
||||||
Assert.Equal(3, service.TrainCalls);
|
Assert.Equal(3, service.TrainCalls);
|
||||||
Assert.Equal(new[] { 10, 10, 5 }, service.TrainBatches.Select(batch => batch.Items.Count).ToArray());
|
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 не переживает проход
|
Assert.False(tenantContext.HasTenant); // контекст AsyncLocal не переживает проход
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -65,23 +65,23 @@ public sealed class MlOutboxFlushSchedulerTests
|
|||||||
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
||||||
{
|
{
|
||||||
service.TrainUnavailable = true;
|
service.TrainUnavailable = true;
|
||||||
var store = new FakeMlLearningStore();
|
var store = new TestMlLearningStore();
|
||||||
SeedRows(store, count: 5, prefix: "a");
|
SeedRows(store, count: 5, prefix: "a");
|
||||||
var tenantContext = new TenantContext();
|
var tenantContext = new TenantContext();
|
||||||
await using ServiceProvider provider = BuildProvider(
|
await using ServiceProvider provider = BuildProvider(
|
||||||
port,
|
port,
|
||||||
new TestTenantRepository(Tenant(TenantA)),
|
new TestTenantRepository(Tenant(TenantA)),
|
||||||
tenantContext,
|
tenantContext,
|
||||||
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store });
|
new Dictionary<Guid, TestMlLearningStore> { [TenantA] = store });
|
||||||
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
|
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
|
||||||
|
|
||||||
await scheduler.RunCycleAsync(CancellationToken.None);
|
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);
|
await scheduler.RunCycleAsync(CancellationToken.None);
|
||||||
Assert.Equal(2, service.TrainCalls);
|
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);
|
Assert.False(tenantContext.HasTenant);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -91,16 +91,16 @@ public sealed class MlOutboxFlushSchedulerTests
|
|||||||
{
|
{
|
||||||
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
||||||
{
|
{
|
||||||
var storeA = new FakeMlLearningStore();
|
var storeA = new TestMlLearningStore();
|
||||||
SeedRows(storeA, count: 12, prefix: "a");
|
SeedRows(storeA, count: 12, prefix: "a");
|
||||||
var storeB = new FakeMlLearningStore();
|
var storeB = new TestMlLearningStore();
|
||||||
SeedRows(storeB, count: 3, prefix: "b");
|
SeedRows(storeB, count: 3, prefix: "b");
|
||||||
var tenantContext = new TenantContext();
|
var tenantContext = new TenantContext();
|
||||||
await using ServiceProvider provider = BuildProvider(
|
await using ServiceProvider provider = BuildProvider(
|
||||||
port,
|
port,
|
||||||
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)),
|
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)),
|
||||||
tenantContext,
|
tenantContext,
|
||||||
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = storeA, [TenantB] = storeB });
|
new Dictionary<Guid, TestMlLearningStore> { [TenantA] = storeA, [TenantB] = storeB });
|
||||||
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
|
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
|
||||||
|
|
||||||
await scheduler.RunCycleAsync(CancellationToken.None);
|
await scheduler.RunCycleAsync(CancellationToken.None);
|
||||||
@@ -108,8 +108,8 @@ public sealed class MlOutboxFlushSchedulerTests
|
|||||||
// Каждый тенант выгрузил свою очередь в собственном scope (A: 10+2, B: 3) — строки удалены.
|
// Каждый тенант выгрузил свою очередь в собственном scope (A: 10+2, B: 3) — строки удалены.
|
||||||
Assert.Equal(3, service.TrainCalls);
|
Assert.Equal(3, service.TrainCalls);
|
||||||
Assert.Equal(new[] { 10, 2, 3 }, service.TrainBatches.Select(batch => batch.Items.Count).ToArray());
|
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 storeA.Store.CountOutboxAsync(CancellationToken.None));
|
||||||
Assert.Equal(0, await storeB.CountOutboxAsync(CancellationToken.None));
|
Assert.Equal(0, await storeB.Store.CountOutboxAsync(CancellationToken.None));
|
||||||
Assert.False(tenantContext.HasTenant);
|
Assert.False(tenantContext.HasTenant);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -119,20 +119,20 @@ public sealed class MlOutboxFlushSchedulerTests
|
|||||||
{
|
{
|
||||||
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
||||||
{
|
{
|
||||||
var store = new FakeMlLearningStore();
|
var store = new TestMlLearningStore();
|
||||||
SeedRows(store, count: 105, prefix: "a");
|
SeedRows(store, count: 105, prefix: "a");
|
||||||
var tenantContext = new TenantContext();
|
var tenantContext = new TenantContext();
|
||||||
await using ServiceProvider provider = BuildProvider(
|
await using ServiceProvider provider = BuildProvider(
|
||||||
port,
|
port,
|
||||||
new TestTenantRepository(Tenant(TenantA)),
|
new TestTenantRepository(Tenant(TenantA)),
|
||||||
tenantContext,
|
tenantContext,
|
||||||
new Dictionary<Guid, FakeMlLearningStore> { [TenantA] = store });
|
new Dictionary<Guid, TestMlLearningStore> { [TenantA] = store });
|
||||||
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
|
MlOutboxFlushScheduler scheduler = CreateScheduler(provider);
|
||||||
|
|
||||||
await scheduler.RunCycleAsync(CancellationToken.None);
|
await scheduler.RunCycleAsync(CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(10, service.TrainCalls);
|
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,
|
int port,
|
||||||
TestTenantRepository tenants,
|
TestTenantRepository tenants,
|
||||||
TenantContext tenantContext,
|
TenantContext tenantContext,
|
||||||
Dictionary<Guid, FakeMlLearningStore> storesByTenant)
|
Dictionary<Guid, TestMlLearningStore> storesByTenant)
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
services.AddSingleton<ITenantContext>(tenantContext);
|
services.AddSingleton<ITenantContext>(tenantContext);
|
||||||
@@ -162,9 +162,9 @@ public sealed class MlOutboxFlushSchedulerTests
|
|||||||
services.AddScoped<IMlClient>(provider => provider.GetRequiredService<GrpcMlClient>());
|
services.AddScoped<IMlClient>(provider => provider.GetRequiredService<GrpcMlClient>());
|
||||||
services.AddScoped<IMlTrainClient>(provider => provider.GetRequiredService<GrpcMlClient>());
|
services.AddScoped<IMlTrainClient>(provider => provider.GetRequiredService<GrpcMlClient>());
|
||||||
// Tenant-scoped адаптеры: выбирают фейк по тому же ITenantContext, который планировщик заполняет SetTenant.
|
// Tenant-scoped адаптеры: выбирают фейк по тому же ITenantContext, который планировщик заполняет SetTenant.
|
||||||
services.AddScoped<IMlLearningStore>(provider => storesByTenant[TenantOf(provider)]);
|
services.AddScoped<IMlLearningStore>(provider => storesByTenant[TenantOf(provider)].Store);
|
||||||
services.AddScoped<ISettingsStore>(_ => new FakeSettingsStore());
|
services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store);
|
||||||
services.AddScoped<ITenantLimitStore>(_ => new FakeTenantLimitStore());
|
services.AddScoped<ITenantLimitStore>(_ => new TestTenantLimitStore().Store);
|
||||||
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
|
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
|
||||||
services.AddScoped<TokenUsageRecorder>();
|
services.AddScoped<TokenUsageRecorder>();
|
||||||
services.AddLogging();
|
services.AddLogging();
|
||||||
@@ -196,7 +196,7 @@ public sealed class MlOutboxFlushSchedulerTests
|
|||||||
// count: Число строк.
|
// count: Число строк.
|
||||||
// prefix: Префикс id (различает тенантов теста).
|
// prefix: Префикс id (различает тенантов теста).
|
||||||
private static void SeedRows(
|
private static void SeedRows(
|
||||||
FakeMlLearningStore store,
|
TestMlLearningStore store,
|
||||||
int count,
|
int count,
|
||||||
string prefix)
|
string prefix)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -128,13 +128,13 @@ public sealed class OperatorBootstrapHostedServiceTests
|
|||||||
IConfiguration configuration = new ConfigurationBuilder()
|
IConfiguration configuration = new ConfigurationBuilder()
|
||||||
.AddInMemoryCollection(values)
|
.AddInMemoryCollection(values)
|
||||||
.Build();
|
.Build();
|
||||||
var store = new FakeOperatorAuthStore();
|
var store = new TestOperatorAuthStore();
|
||||||
var passwordHasher = TestHashers.New();
|
var passwordHasher = TestHashers.New();
|
||||||
var logger = new ListLogger();
|
var logger = new ListLogger();
|
||||||
// Hosted-шаг резолвит scoped OperatorBootstrapService из scope (как TenantBootstrapService):
|
// Hosted-шаг резолвит scoped OperatorBootstrapService из scope (как TenantBootstrapService):
|
||||||
// провайдер собирается с реальной регистрацией модуля поверх фейков.
|
// провайдер собирается с реальной регистрацией модуля поверх фейков.
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
services.AddScoped<IOperatorAuthStore>(_ => store);
|
services.AddScoped<IOperatorAuthStore>(_ => store.Store);
|
||||||
services.AddScoped<IPasswordHasher>(_ => passwordHasher);
|
services.AddScoped<IPasswordHasher>(_ => passwordHasher);
|
||||||
services.AddScoped<OperatorBootstrapService>();
|
services.AddScoped<OperatorBootstrapService>();
|
||||||
// Провайдер живёт всё время теста (без using): hosted резолвит scope в StartAsync после создания.
|
// Провайдер живёт всё время теста (без using): hosted резолвит scope в StartAsync после создания.
|
||||||
@@ -150,7 +150,7 @@ public sealed class OperatorBootstrapHostedServiceTests
|
|||||||
// Контекст теста bootstrap-шага.
|
// Контекст теста bootstrap-шага.
|
||||||
private sealed record Context(
|
private sealed record Context(
|
||||||
OperatorBootstrapHostedService Hosted,
|
OperatorBootstrapHostedService Hosted,
|
||||||
FakeOperatorAuthStore Store,
|
TestOperatorAuthStore Store,
|
||||||
IPasswordHasher PasswordHasher,
|
IPasswordHasher PasswordHasher,
|
||||||
ListLogger Logs);
|
ListLogger Logs);
|
||||||
|
|
||||||
|
|||||||
@@ -29,19 +29,19 @@ public sealed class RuntimeDepthsCollectorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CollectAsync_SumsQueueAndOutboxAcrossTenants()
|
public async Task CollectAsync_SumsQueueAndOutboxAcrossTenants()
|
||||||
{
|
{
|
||||||
var pipelineByTenant = new Dictionary<string, FakePipelineStore>
|
var pipelineByTenant = new Dictionary<string, TestPipelineStore>
|
||||||
{
|
{
|
||||||
[TenantA.ToString("N")] = new FakePipelineStore(),
|
[TenantA.ToString("N")] = new TestPipelineStore(),
|
||||||
[TenantB.ToString("N")] = new FakePipelineStore(),
|
[TenantB.ToString("N")] = new TestPipelineStore(),
|
||||||
};
|
};
|
||||||
pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a1"));
|
pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a1"));
|
||||||
pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a2"));
|
pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a2"));
|
||||||
pipelineByTenant[TenantB.ToString("N")].SeedQueue(QueueItem("p_b1"));
|
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(),
|
[TenantA.ToString("N")] = new TestMlLearningStore(),
|
||||||
[TenantB.ToString("N")] = new FakeMlLearningStore(),
|
[TenantB.ToString("N")] = new TestMlLearningStore(),
|
||||||
};
|
};
|
||||||
outboxByTenant[TenantA.ToString("N")].SeedOutbox("mle_1", "текст", "spam");
|
outboxByTenant[TenantA.ToString("N")].SeedOutbox("mle_1", "текст", "spam");
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@ public sealed class RuntimeDepthsCollectorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CollectAsync_CollectsBudgetRatiosPerTenant()
|
public async Task CollectAsync_CollectsBudgetRatiosPerTenant()
|
||||||
{
|
{
|
||||||
var limits = new FakeTenantLimitStore();
|
var limits = new TestTenantLimitStore();
|
||||||
DateTimeOffset periodStart = DateTimeOffset.UtcNow;
|
DateTimeOffset periodStart = DateTimeOffset.UtcNow;
|
||||||
limits.Preload(TenantA, 100, TenantLimitPeriods.Month, periodStart, 80);
|
limits.Preload(TenantA, 100, TenantLimitPeriods.Month, periodStart, 80);
|
||||||
limits.Preload(TenantB, 0, TenantLimitPeriods.Month, periodStart, 55);
|
limits.Preload(TenantB, 0, TenantLimitPeriods.Month, periodStart, 55);
|
||||||
@@ -69,7 +69,7 @@ public sealed class RuntimeDepthsCollectorTests
|
|||||||
PipelineStores(TenantA, TenantB, TenantC),
|
PipelineStores(TenantA, TenantB, TenantC),
|
||||||
OutboxStores(TenantA, TenantB, TenantC),
|
OutboxStores(TenantA, TenantB, TenantC),
|
||||||
TenantRecords(TenantA, TenantB, TenantC),
|
TenantRecords(TenantA, TenantB, TenantC),
|
||||||
limits);
|
limits.Store);
|
||||||
|
|
||||||
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
|
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
|
||||||
|
|
||||||
@@ -83,7 +83,7 @@ public sealed class RuntimeDepthsCollectorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CollectAsync_BudgetReadFailure_SkipsFailedTenant()
|
public async Task CollectAsync_BudgetReadFailure_SkipsFailedTenant()
|
||||||
{
|
{
|
||||||
var limits = new FakeTenantLimitStore();
|
var limits = new TestTenantLimitStore();
|
||||||
limits.FailStateReads.Add(TenantA);
|
limits.FailStateReads.Add(TenantA);
|
||||||
limits.Preload(TenantB, 100, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 80);
|
limits.Preload(TenantB, 100, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 80);
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ public sealed class RuntimeDepthsCollectorTests
|
|||||||
PipelineStores(TenantA, TenantB),
|
PipelineStores(TenantA, TenantB),
|
||||||
OutboxStores(TenantA, TenantB),
|
OutboxStores(TenantA, TenantB),
|
||||||
TenantRecords(TenantA, TenantB),
|
TenantRecords(TenantA, TenantB),
|
||||||
limits);
|
limits.Store);
|
||||||
|
|
||||||
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
|
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
|
||||||
|
|
||||||
@@ -105,8 +105,8 @@ public sealed class RuntimeDepthsCollectorTests
|
|||||||
public async Task CollectAsync_NoTenants_ReturnsZeros()
|
public async Task CollectAsync_NoTenants_ReturnsZeros()
|
||||||
{
|
{
|
||||||
RuntimeDepthsCollector collector = Build(
|
RuntimeDepthsCollector collector = Build(
|
||||||
new Dictionary<string, FakePipelineStore>(),
|
new Dictionary<string, TestPipelineStore>(),
|
||||||
new Dictionary<string, FakeMlLearningStore>(),
|
new Dictionary<string, TestMlLearningStore>(),
|
||||||
tenants: Array.Empty<TenantRecordDto>());
|
tenants: Array.Empty<TenantRecordDto>());
|
||||||
|
|
||||||
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
|
RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None);
|
||||||
@@ -123,11 +123,11 @@ public sealed class RuntimeDepthsCollectorTests
|
|||||||
Status = PipelineQueueStatuses.New,
|
Status = PipelineQueueStatuses.New,
|
||||||
};
|
};
|
||||||
|
|
||||||
private static Dictionary<string, FakePipelineStore> PipelineStores(params Guid[] tenants)
|
private static Dictionary<string, TestPipelineStore> PipelineStores(params Guid[] tenants)
|
||||||
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakePipelineStore());
|
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new TestPipelineStore());
|
||||||
|
|
||||||
private static Dictionary<string, FakeMlLearningStore> OutboxStores(params Guid[] tenants)
|
private static Dictionary<string, TestMlLearningStore> OutboxStores(params Guid[] tenants)
|
||||||
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakeMlLearningStore());
|
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new TestMlLearningStore());
|
||||||
|
|
||||||
private static TenantRecordDto[] TenantRecords(params Guid[] tenants)
|
private static TenantRecordDto[] TenantRecords(params Guid[] tenants)
|
||||||
=> tenants
|
=> tenants
|
||||||
@@ -139,8 +139,8 @@ public sealed class RuntimeDepthsCollectorTests
|
|||||||
|
|
||||||
// Собирает коллектор поверх tenant-scoped фейков (как реальные адаптеры по ITenantContext).
|
// Собирает коллектор поверх tenant-scoped фейков (как реальные адаптеры по ITenantContext).
|
||||||
private static RuntimeDepthsCollector Build(
|
private static RuntimeDepthsCollector Build(
|
||||||
IReadOnlyDictionary<string, FakePipelineStore> pipelineByTenant,
|
IReadOnlyDictionary<string, TestPipelineStore> pipelineByTenant,
|
||||||
IReadOnlyDictionary<string, FakeMlLearningStore> outboxByTenant,
|
IReadOnlyDictionary<string, TestMlLearningStore> outboxByTenant,
|
||||||
IReadOnlyList<TenantRecordDto>? tenants = null,
|
IReadOnlyList<TenantRecordDto>? tenants = null,
|
||||||
ITenantLimitStore? limitStore = null)
|
ITenantLimitStore? limitStore = null)
|
||||||
{
|
{
|
||||||
@@ -153,12 +153,12 @@ public sealed class RuntimeDepthsCollectorTests
|
|||||||
new TenantRecordDto(TenantB, "B", "active", DateTimeOffset.UtcNow),
|
new TenantRecordDto(TenantB, "B", "active", DateTimeOffset.UtcNow),
|
||||||
}).ToArray()).Repository);
|
}).ToArray()).Repository);
|
||||||
|
|
||||||
services.AddScoped<IPipelineStore>(provider => pipelineByTenant[CurrentTenant(provider)]);
|
services.AddScoped<IPipelineStore>(provider => pipelineByTenant[CurrentTenant(provider)].Store);
|
||||||
services.AddScoped<IMlClient>(_ => new FakeMlClient());
|
services.AddScoped<IMlClient>(_ => new TestMlClient().Client);
|
||||||
services.AddScoped<PipelineIngestService>();
|
services.AddScoped<PipelineIngestService>();
|
||||||
services.AddScoped<PipelineProcessingService>();
|
services.AddScoped<PipelineProcessingService>();
|
||||||
services.AddScoped<IMlLearningStore>(provider => outboxByTenant[CurrentTenant(provider)]);
|
services.AddScoped<IMlLearningStore>(provider => outboxByTenant[CurrentTenant(provider)].Store);
|
||||||
services.AddSingleton<ITenantLimitStore>(limitStore ?? new FakeTenantLimitStore());
|
services.AddSingleton<ITenantLimitStore>(limitStore ?? new TestTenantLimitStore().Store);
|
||||||
|
|
||||||
ServiceProvider provider = services.BuildServiceProvider();
|
ServiceProvider provider = services.BuildServiceProvider();
|
||||||
return new RuntimeDepthsCollector(
|
return new RuntimeDepthsCollector(
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ public sealed class TgStatusServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAsync_GatewayUnavailable_ReturnsIdleFormWithKvAccountCountAndKeys()
|
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_1", "Канал", "channel", Monitor: true));
|
||||||
store.Seed(Dialog("d_2", "Группа", "group", Monitor: true));
|
store.Seed(Dialog("d_2", "Группа", "group", Monitor: true));
|
||||||
store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false));
|
store.Seed(Dialog("d_off", "Выключен", "channel", Monitor: false));
|
||||||
@@ -69,7 +69,7 @@ public sealed class TgStatusServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAsync_ReadyGateway_ComposesLiveFieldsWithKvAccountMonitoredAndKeys()
|
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));
|
store.Seed(Dialog("d_1", "Канал", "channel", Monitor: true));
|
||||||
settings.Preload(SettingsKeys.TgAccount, "\"@realuser\"");
|
settings.Preload(SettingsKeys.TgAccount, "\"@realuser\"");
|
||||||
PreloadKeys(globalSettings, "123456", "abcdefghijklmnop");
|
PreloadKeys(globalSettings, "123456", "abcdefghijklmnop");
|
||||||
@@ -106,16 +106,16 @@ public sealed class TgStatusServiceTests
|
|||||||
// ─── Хелперы ────────────────────────────────────────────────────────────
|
// ─── Хелперы ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Собирает сервис на фейках (FakeTelegramGateway/Store/SettingsStore/GlobalSettingsStore + ISecretCipher).
|
// Собирает сервис на фейках (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 store = new TestTelegramStore();
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
var gateway = new TestTelegramGateway();
|
var gateway = new TestTelegramGateway();
|
||||||
var cipher = TestCiphers.New();
|
var cipher = TestCiphers.New();
|
||||||
var globalSettings = new TestGlobalSettingsStore();
|
var globalSettings = new TestGlobalSettingsStore();
|
||||||
var keys = new TelegramKeysService(globalSettings.Store, cipher);
|
var keys = new TelegramKeysService(globalSettings.Store, cipher);
|
||||||
var dialogs = new DialogsService(store.Store, settings, gateway.Gateway, NullLogger<DialogsService>.Instance);
|
var dialogs = new DialogsService(store.Store, settings.Store, gateway.Gateway, NullLogger<DialogsService>.Instance);
|
||||||
var service = new TgStatusService(gateway.Gateway, dialogs, settings, keys);
|
var service = new TgStatusService(gateway.Gateway, dialogs, settings.Store, keys);
|
||||||
return (service, store, settings, gateway, cipher, globalSettings);
|
return (service, store, settings, gateway, cipher, globalSettings);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ namespace Deal.Tests.Unit.Contracts;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class BudgetedAiClassifierTests
|
public sealed class BudgetedAiClassifierTests
|
||||||
{
|
{
|
||||||
// Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита FakeTenantLimitStore.
|
// Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита TestTenantLimitStore.
|
||||||
private const string TenantIdValue = "0123456789abcdef0123456789abcdef";
|
private const string TenantIdValue = "0123456789abcdef0123456789abcdef";
|
||||||
|
|
||||||
// Guid того же тенанта — ключ строки лимита.
|
// Guid того же тенанта — ключ строки лимита.
|
||||||
@@ -128,7 +128,7 @@ public sealed class BudgetedAiClassifierTests
|
|||||||
Context ctx = Create(
|
Context ctx = Create(
|
||||||
limits => limits.Preload(
|
limits => limits.Preload(
|
||||||
TenantGuid, budgetTokens: 0, TenantLimitPeriods.Month, PeriodStart, usedTokens: 0));
|
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к$";
|
const string text = "Ищем Python-разработчика на проект в команду\nБюджет: до 2к$";
|
||||||
|
|
||||||
AiParsedCardDto result = await ctx.Decorator.ClassifyAsync(text, CancellationToken.None);
|
AiParsedCardDto result = await ctx.Decorator.ClassifyAsync(text, CancellationToken.None);
|
||||||
@@ -152,24 +152,24 @@ public sealed class BudgetedAiClassifierTests
|
|||||||
private sealed record Context(
|
private sealed record Context(
|
||||||
IAiClassifier Decorator,
|
IAiClassifier Decorator,
|
||||||
TestAiClassifier Paid,
|
TestAiClassifier Paid,
|
||||||
FakeSettingsStore Settings);
|
TestSettingsStore Settings);
|
||||||
|
|
||||||
// Собирает контекст: платный фейк + реальный Local-классификатор + фейк лимитов и tenant-контекст
|
// Собирает контекст: платный фейк + реальный Local-классификатор + фейк лимитов и tenant-контекст
|
||||||
// (зеркало регистрации AddDealIntegrations при UseLocal=false).
|
// (зеркало регистрации AddDealIntegrations при UseLocal=false).
|
||||||
// configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет).
|
// configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет).
|
||||||
// Возвращает: Контекст теста.
|
// Возвращает: Контекст теста.
|
||||||
private static Context Create(Action<FakeTenantLimitStore>? configure = null)
|
private static Context Create(Action<TestTenantLimitStore>? configure = null)
|
||||||
{
|
{
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
var limits = new FakeTenantLimitStore();
|
var limits = new TestTenantLimitStore();
|
||||||
configure?.Invoke(limits);
|
configure?.Invoke(limits);
|
||||||
ITenantContext tenantContext = new TenantContext();
|
ITenantContext tenantContext = new TenantContext();
|
||||||
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
||||||
var paid = new TestAiClassifier();
|
var paid = new TestAiClassifier();
|
||||||
IAiClassifier decorator = new BudgetedAiClassifier(
|
IAiClassifier decorator = new BudgetedAiClassifier(
|
||||||
paid.Classifier,
|
paid.Classifier,
|
||||||
new LocalAiClassifier(new LocalFieldsParser(settings)),
|
new LocalAiClassifier(new LocalFieldsParser(settings.Store)),
|
||||||
limits,
|
limits.Store,
|
||||||
tenantContext,
|
tenantContext,
|
||||||
NullLogger<BudgetedAiClassifier>.Instance);
|
NullLogger<BudgetedAiClassifier>.Instance);
|
||||||
return new Context(decorator, paid, settings);
|
return new Context(decorator, paid, settings);
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ namespace Deal.Tests.Unit.Contracts;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class BudgetedAiToolsTests
|
public sealed class BudgetedAiToolsTests
|
||||||
{
|
{
|
||||||
// Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита FakeTenantLimitStore.
|
// Id тенанта сценариев строкой (формат N) — Guid ключа строк лимита TestTenantLimitStore.
|
||||||
private const string TenantIdValue = "0123456789abcdef0123456789abcdef";
|
private const string TenantIdValue = "0123456789abcdef0123456789abcdef";
|
||||||
|
|
||||||
// Guid того же тенанта — ключ строки лимита.
|
// Guid того же тенанта — ключ строки лимита.
|
||||||
@@ -139,16 +139,16 @@ public sealed class BudgetedAiToolsTests
|
|||||||
// AddDealIntegrations при UseLocal=false).
|
// AddDealIntegrations при UseLocal=false).
|
||||||
// configure: Настройка строки лимита сценария (null — строки нет, ленивый дефолт-бюджет).
|
// 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);
|
configure?.Invoke(limits);
|
||||||
ITenantContext tenantContext = new TenantContext();
|
ITenantContext tenantContext = new TenantContext();
|
||||||
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
||||||
var paid = TestAiTools.New();
|
var paid = TestAiTools.New();
|
||||||
IAiTools decorator = new BudgetedAiTools(
|
IAiTools decorator = new BudgetedAiTools(
|
||||||
paid,
|
paid,
|
||||||
limits,
|
limits.Store,
|
||||||
tenantContext,
|
tenantContext,
|
||||||
NullLogger<BudgetedAiTools>.Instance);
|
NullLogger<BudgetedAiTools>.Instance);
|
||||||
return new Context(decorator, paid);
|
return new Context(decorator, paid);
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ using Deal.Modules.Pipeline.Application.Models;
|
|||||||
using Deal.Modules.Pipeline.Application.Services;
|
using Deal.Modules.Pipeline.Application.Services;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
using Deal.Modules.Settings.Application.Services;
|
using Deal.Modules.Settings.Application.Services;
|
||||||
using Deal.Tests.Unit.Modules.Cards;
|
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
using Deal.Tests.Unit.Support;
|
using Deal.Tests.Unit.Support;
|
||||||
|
|
||||||
@@ -109,7 +108,7 @@ public sealed class CardComposerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task BuildAsync_BoardWithoutRules_PlacesCardIntoBoardWithEmptyMatchHits()
|
public async Task BuildAsync_BoardWithoutRules_PlacesCardIntoBoardWithEmptyMatchHits()
|
||||||
{
|
{
|
||||||
(CardComposer composer, FakeKanjStore store, _) = Create();
|
(CardComposer composer, TestKanjStore store, _) = Create();
|
||||||
store.SeedBoard(new ContainerDto { Id = "b_py" });
|
store.SeedBoard(new ContainerDto { Id = "b_py" });
|
||||||
|
|
||||||
CardSnapshot snapshot = await composer.BuildAsync(
|
CardSnapshot snapshot = await composer.BuildAsync(
|
||||||
@@ -125,7 +124,7 @@ public sealed class CardComposerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task BuildAsync_BoardWithNonMatchingRules_FallsBackToInbox()
|
public async Task BuildAsync_BoardWithNonMatchingRules_FallsBackToInbox()
|
||||||
{
|
{
|
||||||
(CardComposer composer, FakeKanjStore store, _) = Create();
|
(CardComposer composer, TestKanjStore store, _) = Create();
|
||||||
store.SeedBoard(BoardWithRules("b_wpf", new[] { "wpf" }));
|
store.SeedBoard(BoardWithRules("b_wpf", new[] { "wpf" }));
|
||||||
|
|
||||||
CardSnapshot snapshot = await composer.BuildAsync(
|
CardSnapshot snapshot = await composer.BuildAsync(
|
||||||
@@ -141,7 +140,7 @@ public sealed class CardComposerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task BuildAsync_BoardWithMatchingRules_PlacesCardAndComputesMatchHits()
|
public async Task BuildAsync_BoardWithMatchingRules_PlacesCardAndComputesMatchHits()
|
||||||
{
|
{
|
||||||
(CardComposer composer, FakeKanjStore store, _) = Create();
|
(CardComposer composer, TestKanjStore store, _) = Create();
|
||||||
store.SeedBoard(BoardWithRules("b_py", new[] { "python" }));
|
store.SeedBoard(BoardWithRules("b_py", new[] { "python" }));
|
||||||
|
|
||||||
CardSnapshot snapshot = await composer.BuildAsync(
|
CardSnapshot snapshot = await composer.BuildAsync(
|
||||||
@@ -193,7 +192,7 @@ public sealed class CardComposerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task BuildAsync_ConversionDisabled_LeavesConvFieldsEmpty()
|
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
|
settings.Preload(SettingsKeys.ConversionOn, "false"); // budget_to_target L348–349
|
||||||
|
|
||||||
CardSnapshot snapshot = await composer.BuildAsync(
|
CardSnapshot snapshot = await composer.BuildAsync(
|
||||||
@@ -232,11 +231,11 @@ public sealed class CardComposerTests
|
|||||||
|
|
||||||
// Создаёт контекст теста: пустые хранилища (дефолты: конверсия включена, курсы — мок).
|
// Создаёт контекст теста: пустые хранилища (дефолты: конверсия включена, курсы — мок).
|
||||||
// Возвращает: Кортеж (композитор, канбан-хранилище, KV-настройки).
|
// Возвращает: Кортеж (композитор, канбан-хранилище, KV-настройки).
|
||||||
private static (CardComposer Composer, FakeKanjStore Store, FakeSettingsStore Settings) Create()
|
private static (CardComposer Composer, TestKanjStore Store, TestSettingsStore Settings) Create()
|
||||||
{
|
{
|
||||||
var store = new FakeKanjStore();
|
var store = new TestKanjStore();
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
return (new CardComposer(store, settings), store, settings);
|
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.Cards.Application.Sources;
|
||||||
using Deal.Modules.Kanban.Application.Models;
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
using Deal.Modules.Kanban.Application.Services;
|
using Deal.Modules.Kanban.Application.Services;
|
||||||
using Deal.Tests.Unit.Modules.Cards;
|
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Contracts;
|
namespace Deal.Tests.Unit.Contracts;
|
||||||
@@ -17,7 +16,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ListCards_NoCol_ReturnsAllOrderedByReceivedAtDesc()
|
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_1", "inbox", receivedAtMs: 100));
|
||||||
store.SeedCard(Card("l_2", "b_x", receivedAtMs: 300));
|
store.SeedCard(Card("l_2", "b_x", receivedAtMs: 300));
|
||||||
|
|
||||||
@@ -30,7 +29,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ListCards_ByColumn_ReturnsOnlyColumnCards()
|
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_1", "inbox"));
|
||||||
store.SeedCard(Card("l_2", KanbanColumns.Trash));
|
store.SeedCard(Card("l_2", KanbanColumns.Trash));
|
||||||
|
|
||||||
@@ -43,7 +42,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetCard_Existing_ReturnsCard()
|
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"));
|
store.SeedCard(Card("l_1", "inbox", title: "Middle Python"));
|
||||||
|
|
||||||
CardDto? card = await service.GetCardAsync("l_1", CancellationToken.None);
|
CardDto? card = await service.GetCardAsync("l_1", CancellationToken.None);
|
||||||
@@ -66,7 +65,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Move_ToBoardWithRules_UpdatesColumnAndComputesMatchHits()
|
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.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
|
||||||
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Middle Python-разработчик на бота, 1600$."));
|
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Middle Python-разработчик на бота, 1600$."));
|
||||||
|
|
||||||
@@ -84,7 +83,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Move_ToBoard_WritesJournalAndPush()
|
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.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
|
||||||
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
|
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
|
||||||
|
|
||||||
@@ -105,7 +104,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Move_ToInbox_WritesJournalWithoutPush()
|
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.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
|
||||||
store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", prevCol: "inbox"));
|
store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", prevCol: "inbox"));
|
||||||
|
|
||||||
@@ -124,7 +123,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Move_ToUnknownBoard_Returns400Text()
|
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-разработчик"));
|
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
|
||||||
|
|
||||||
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_ghost", CancellationToken.None);
|
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_ghost", CancellationToken.None);
|
||||||
@@ -148,7 +147,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Move_CardMissing_ReturnsNullLead()
|
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"));
|
store.SeedBoard(Board("b_py"));
|
||||||
|
|
||||||
CardResultDto result = await service.MoveDashboardCardAsync("l_ghost", "b_py", CancellationToken.None);
|
CardResultDto result = await service.MoveDashboardCardAsync("l_ghost", "b_py", CancellationToken.None);
|
||||||
@@ -162,7 +161,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Move_SameColumn_NoOpWithoutJournalAndPush()
|
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.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
|
||||||
store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", isNew: true));
|
store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", isNew: true));
|
||||||
|
|
||||||
@@ -179,7 +178,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Move_ToBoardWithoutRules_MatchHitsEmptyButPushSent()
|
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.SeedBoard(Board("b_free")); // правил нет
|
||||||
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Любой текст"));
|
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Любой текст"));
|
||||||
|
|
||||||
@@ -195,7 +194,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Move_FromBoardToBoard_PrevColIsOldBoardAndPushNewLabel()
|
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_java", Rules(stack: new[] { "java" })));
|
||||||
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
|
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
|
||||||
store.SeedCard(Card("l_1", "b_java", sourceMsg: "Нужен Java-разработчик", prevCol: "inbox"));
|
store.SeedCard(Card("l_1", "b_java", sourceMsg: "Нужен Java-разработчик", prevCol: "inbox"));
|
||||||
@@ -214,7 +213,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Move_EmptySourceText_UsesTitleForMatchHitsAndPush()
|
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.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
|
||||||
store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", sourceMsg: " "));
|
store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", sourceMsg: " "));
|
||||||
|
|
||||||
@@ -228,7 +227,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Move_EmptySourceAndTitle_JournalWithoutPush()
|
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.SeedBoard(Board("b_py"));
|
||||||
store.SeedCard(Card("l_1", "inbox"));
|
store.SeedCard(Card("l_1", "inbox"));
|
||||||
|
|
||||||
@@ -241,7 +240,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Move_ToBoardWithGradeAndBudget_HitsIncludeGradeWordAndBudget()
|
public async Task Move_ToBoardWithGradeAndBudget_HitsIncludeGradeWordAndBudget()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||||
store.SeedBoard(Board("b_mid", Rules(
|
store.SeedBoard(Board("b_mid", Rules(
|
||||||
grade: new[] { "middle" },
|
grade: new[] { "middle" },
|
||||||
budget: new BudgetRangeDto(1000, 3000, "USD"))));
|
budget: new BudgetRangeDto(1000, 3000, "USD"))));
|
||||||
@@ -263,7 +262,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Move_FromArchive_Returns400AndWritesNothing()
|
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.SeedBoard(Board("b_py"));
|
||||||
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая вакансия", prevCol: "b_py"));
|
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая вакансия", prevCol: "b_py"));
|
||||||
|
|
||||||
@@ -279,7 +278,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Move_FromTrash_Returns400AndWritesNothing()
|
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.SeedBoard(Board("b_py"));
|
||||||
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Спам-текст", prevCol: "b_py"));
|
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Спам-текст", prevCol: "b_py"));
|
||||||
|
|
||||||
@@ -296,7 +295,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Trash_FromInbox_MovesToTrashWithJournalAndSpamPush()
|
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));
|
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Предлагаю услуги продвижения", isNew: true));
|
||||||
|
|
||||||
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
|
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
|
||||||
@@ -318,7 +317,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Trash_AlreadyInTrash_NoOpWithoutJournalAndPush()
|
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: "Текст"));
|
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст"));
|
||||||
|
|
||||||
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
|
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
|
||||||
@@ -331,7 +330,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Trash_FromArchive_WritesJournalWithoutPush()
|
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"));
|
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py"));
|
||||||
|
|
||||||
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
|
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
|
||||||
@@ -346,7 +345,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Trash_CardMissing_ReturnsNull()
|
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);
|
CardDto? result = await service.TrashCardAsync("l_ghost", CancellationToken.None);
|
||||||
|
|
||||||
@@ -358,7 +357,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Restore_FromTrash_ToPrevColBoard_UnlearnsSpam()
|
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.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
|
||||||
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Нужен Python-разработчик", prevCol: "b_py"));
|
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Нужен Python-разработчик", prevCol: "b_py"));
|
||||||
|
|
||||||
@@ -383,7 +382,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Restore_FromArchive_NoPush()
|
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.SeedBoard(Board("b_py"));
|
||||||
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py"));
|
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py"));
|
||||||
|
|
||||||
@@ -399,7 +398,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Restore_FromTrash_PrevColDeletedBoard_FallsBackToInbox()
|
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"));
|
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст", prevCol: "b_gone"));
|
||||||
|
|
||||||
string? back = await service.RestoreCardAsync("l_1", CancellationToken.None);
|
string? back = await service.RestoreCardAsync("l_1", CancellationToken.None);
|
||||||
@@ -414,7 +413,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Restore_FromTrash_PrevColInbox_ReturnsInbox()
|
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"));
|
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст", prevCol: "inbox"));
|
||||||
|
|
||||||
string? back = await service.RestoreCardAsync("l_1", CancellationToken.None);
|
string? back = await service.RestoreCardAsync("l_1", CancellationToken.None);
|
||||||
@@ -439,7 +438,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteForever_RemovesCardAndComments_KeepsJournal()
|
public async Task DeleteForever_RemovesCardAndComments_KeepsJournal()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||||
store.SeedCard(Card("l_1", KanbanColumns.Trash,
|
store.SeedCard(Card("l_1", KanbanColumns.Trash,
|
||||||
sourceMsg: "Нужен Python-разработчик",
|
sourceMsg: "Нужен Python-разработчик",
|
||||||
comments: [new CardCommentDto("cm_1", "Вы", "Перезвонить", "5 мин")]));
|
comments: [new CardCommentDto("cm_1", "Вы", "Перезвонить", "5 мин")]));
|
||||||
@@ -454,7 +453,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteForever_JournalRowsSurviveCardDeletion()
|
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.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
|
||||||
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
|
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
|
||||||
|
|
||||||
@@ -480,7 +479,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ClearCol_Trash_ReturnsClearedCount()
|
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_1", KanbanColumns.Trash));
|
||||||
store.SeedCard(Card("l_2", KanbanColumns.Trash));
|
store.SeedCard(Card("l_2", KanbanColumns.Trash));
|
||||||
store.SeedCard(Card("l_3", KanbanColumns.Archive));
|
store.SeedCard(Card("l_3", KanbanColumns.Archive));
|
||||||
@@ -507,7 +506,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ClearCol_Board_Returns400Text()
|
public async Task ClearCol_Board_Returns400Text()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||||
store.SeedBoard(Board("b_py"));
|
store.SeedBoard(Board("b_py"));
|
||||||
store.SeedCard(Card("l_1", "b_py"));
|
store.SeedCard(Card("l_1", "b_py"));
|
||||||
|
|
||||||
@@ -531,7 +530,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task MarkSeen_ById_OnlyThatCard()
|
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_1", "inbox", isNew: true));
|
||||||
store.SeedCard(Card("l_2", "inbox", isNew: true));
|
store.SeedCard(Card("l_2", "inbox", isNew: true));
|
||||||
|
|
||||||
@@ -544,7 +543,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task MarkSeen_ByCol_OnlyColumnCards()
|
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_1", "inbox", isNew: true));
|
||||||
store.SeedCard(Card("l_2", "inbox", isNew: true));
|
store.SeedCard(Card("l_2", "inbox", isNew: true));
|
||||||
store.SeedCard(Card("l_3", "b_py", isNew: true));
|
store.SeedCard(Card("l_3", "b_py", isNew: true));
|
||||||
@@ -558,7 +557,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task MarkSeen_All_WithoutParameters()
|
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_1", "inbox", isNew: true));
|
||||||
store.SeedCard(Card("l_2", KanbanColumns.Trash, isNew: true));
|
store.SeedCard(Card("l_2", KanbanColumns.Trash, isNew: true));
|
||||||
|
|
||||||
@@ -571,7 +570,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddComment_EmptyOrWhitespaceText_Returns400Text()
|
public async Task AddComment_EmptyOrWhitespaceText_Returns400Text()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||||
store.SeedCard(Card("l_1", "inbox"));
|
store.SeedCard(Card("l_1", "inbox"));
|
||||||
|
|
||||||
AddCommentResultDto empty = await service.AddCommentAsync("l_1", " ", CancellationToken.None);
|
AddCommentResultDto empty = await service.AddCommentAsync("l_1", " ", CancellationToken.None);
|
||||||
@@ -585,7 +584,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddComment_Valid_AppendsTrimmedCommentWithJournal()
|
public async Task AddComment_Valid_AppendsTrimmedCommentWithJournal()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||||
store.SeedCard(Card("l_1", "inbox"));
|
store.SeedCard(Card("l_1", "inbox"));
|
||||||
|
|
||||||
AddCommentResultDto result = await service.AddCommentAsync("l_1", " Перезвонить завтра ", CancellationToken.None);
|
AddCommentResultDto result = await service.AddCommentAsync("l_1", " Перезвонить завтра ", CancellationToken.None);
|
||||||
@@ -618,7 +617,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Counts_ColumnsNewAndMlStats()
|
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_1", "inbox", isNew: true));
|
||||||
store.SeedCard(Card("l_2", "inbox"));
|
store.SeedCard(Card("l_2", "inbox"));
|
||||||
store.SeedCard(Card("l_3", "b_py"));
|
store.SeedCard(Card("l_3", "b_py"));
|
||||||
@@ -652,7 +651,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Search_QueryShorterThanTwoChars_ReturnsEmptyAndDoesNotCallStore()
|
public async Task Search_QueryShorterThanTwoChars_ReturnsEmptyAndDoesNotCallStore()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||||
store.SeedCard(Card("l_1", "inbox", title: "Python-разработчик"));
|
store.SeedCard(Card("l_1", "inbox", title: "Python-разработчик"));
|
||||||
|
|
||||||
IReadOnlyList<CardDto> one = await service.SearchCardsAsync("p", CancellationToken.None);
|
IReadOnlyList<CardDto> one = await service.SearchCardsAsync("p", CancellationToken.None);
|
||||||
@@ -666,7 +665,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Search_DelegatesToStoreWithQueryAndLimit()
|
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));
|
store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300));
|
||||||
|
|
||||||
IReadOnlyList<CardDto> result = await service.SearchCardsAsync(" pYtHoN ", CancellationToken.None);
|
IReadOnlyList<CardDto> result = await service.SearchCardsAsync(" pYtHoN ", CancellationToken.None);
|
||||||
@@ -681,7 +680,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Search_FindsByTitleSummaryContactSourceText_OrderedByReceivedAtDesc()
|
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_title", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300));
|
||||||
store.SeedCard(Card("l_summary", "b_py", summary: "проект на python", receivedAtMs: 200));
|
store.SeedCard(Card("l_summary", "b_py", summary: "проект на python", receivedAtMs: 200));
|
||||||
store.SeedCard(Card("l_contact", "inbox", contact: "@python_dev", receivedAtMs: 100));
|
store.SeedCard(Card("l_contact", "inbox", contact: "@python_dev", receivedAtMs: 100));
|
||||||
@@ -696,7 +695,7 @@ public sealed class CardsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Search_LimitIsTwelve()
|
public async Task Search_LimitIsTwelve()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||||
for (int i = 0; i < 15; i++)
|
for (int i = 0; i < 15; i++)
|
||||||
{
|
{
|
||||||
store.SeedCard(Card($"l_{i:00}", "inbox", title: $"python {i}", receivedAtMs: 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 store = new TestKanjStore();
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
var ml = new FakeMlClient();
|
var ml = new TestMlClient();
|
||||||
return (new CardsService(store, settings, ml, new TestFileStorage().Storage), store, settings, ml);
|
return (new CardsService(store.Store, settings.Store, ml.Client, new TestFileStorage().Storage), store, settings, ml);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Доска с правилами (ContainerRulesDto) либо без них.
|
// Доска с правилами (ContainerRulesDto) либо без них.
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ public sealed class DialogsServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Sync_AutoMonitorNewFalse_AddsNewDialogsUnmonitored()
|
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");
|
settings.Preload(SettingsKeys.AutoMonitorNew, "false");
|
||||||
|
|
||||||
int count = await service.SyncFromTelegramAsync(Catalog(Entry("d_1", "Канал", "channel")), CancellationToken.None);
|
int count = await service.SyncFromTelegramAsync(Catalog(Entry("d_1", "Канал", "channel")), CancellationToken.None);
|
||||||
@@ -497,12 +497,12 @@ public sealed class DialogsServiceTests
|
|||||||
// ─── Хелперы ────────────────────────────────────────────────────────────
|
// ─── Хелперы ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Собирает сервис на фейках (TestTelegramStore — семантика адаптера; настройки/гейт чистые).
|
// Собирает сервис на фейках (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 store = new TestTelegramStore();
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
var gateway = new TestTelegramGateway();
|
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 + флаги/хвост каталога).
|
// Строка каталога минимально (id/имя/kind + флаги/хвост каталога).
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ public sealed class DiscoveryEvaluatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task EvaluateMessage_MlSpam_NotFit()
|
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(
|
ml.Predict = new MlPredictResultDto(
|
||||||
Take: true, Label: "spam", Scores: new Dictionary<string, double>(), Hits: 3, Ready: true, Margin: 0.9,
|
Take: true, Label: "spam", Scores: new Dictionary<string, double>(), Hits: 3, Ready: true, Margin: 0.9,
|
||||||
Terms: Array.Empty<string>(), Type: null);
|
Terms: Array.Empty<string>(), Type: null);
|
||||||
@@ -46,7 +46,7 @@ public sealed class DiscoveryEvaluatorTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task EvaluateMessage_AiFits_ReturnsAiVerdict()
|
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, "тема совпадает"));
|
ai.ReturnsFit(new AiEvaluateFitResultDto(Fit: true, "тема совпадает"));
|
||||||
|
|
||||||
DiscoveryMessageFit fit = await evaluator.EvaluateMessageAsync(
|
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);
|
=> 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.AiEnabled, aiEnabled ? "true" : "false");
|
||||||
settings.Preload(SettingsKeys.MlEnabled, mlEnabled ? "true" : "false");
|
settings.Preload(SettingsKeys.MlEnabled, mlEnabled ? "true" : "false");
|
||||||
var ml = new FakeMlClient();
|
var ml = new TestMlClient();
|
||||||
var ai = TestAiTools.New();
|
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).
|
// Задача как строка DiscTasks (адаптер-форма; нужны только keywords/description/threshold).
|
||||||
|
|||||||
@@ -419,7 +419,7 @@ public sealed class DiscoveryWorkerServiceTests
|
|||||||
// Контекст теста: воркер на фейках + ссылки для проверок состояния.
|
// Контекст теста: воркер на фейках + ссылки для проверок состояния.
|
||||||
private sealed record Fixture(
|
private sealed record Fixture(
|
||||||
TestDiscoveryStore Store,
|
TestDiscoveryStore Store,
|
||||||
FakeSettingsStore Settings,
|
TestSettingsStore Settings,
|
||||||
TestDiscoveryGateway Gateway,
|
TestDiscoveryGateway Gateway,
|
||||||
IDiscoveryPacer Pacer,
|
IDiscoveryPacer Pacer,
|
||||||
DiscoveryWorkerService Worker,
|
DiscoveryWorkerService Worker,
|
||||||
@@ -429,21 +429,21 @@ public sealed class DiscoveryWorkerServiceTests
|
|||||||
private static Fixture Create()
|
private static Fixture Create()
|
||||||
{
|
{
|
||||||
var store = new TestDiscoveryStore();
|
var store = new TestDiscoveryStore();
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
settings.Preload(SettingsKeys.MlEnabled, "false");
|
settings.Preload(SettingsKeys.MlEnabled, "false");
|
||||||
settings.Preload(SettingsKeys.AiEnabled, "false");
|
settings.Preload(SettingsKeys.AiEnabled, "false");
|
||||||
var ml = new FakeMlClient();
|
var ml = new TestMlClient();
|
||||||
var ai = TestAiTools.New();
|
var ai = TestAiTools.New();
|
||||||
var log = new DiscoveryLogService(store.Store);
|
var log = new DiscoveryLogService(store.Store);
|
||||||
var blacklist = new DiscoveryBlacklistService(store.Store);
|
var blacklist = new DiscoveryBlacklistService(store.Store);
|
||||||
var candidates = new DiscoveryCandidatesService(store.Store, log, blacklist);
|
var candidates = new DiscoveryCandidatesService(store.Store, log, blacklist);
|
||||||
var planGuard = new DiscoveryPlanGuard(store.Store, settings);
|
var planGuard = new DiscoveryPlanGuard(store.Store, settings.Store);
|
||||||
var tasks = new DiscoveryTasksService(store.Store, planGuard, settings);
|
var tasks = new DiscoveryTasksService(store.Store, planGuard, settings.Store);
|
||||||
var banGuard = new DiscoveryBanGuard(store.Store, settings);
|
var banGuard = new DiscoveryBanGuard(store.Store, settings.Store);
|
||||||
var pacer = Substitute.For<IDiscoveryPacer>();
|
var pacer = Substitute.For<IDiscoveryPacer>();
|
||||||
var gateway = new TestDiscoveryGateway();
|
var gateway = new TestDiscoveryGateway();
|
||||||
var worker = new DiscoveryWorkerService(
|
var worker = new DiscoveryWorkerService(
|
||||||
store.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);
|
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("python разработка");
|
||||||
reply.Keywords.Add("telegram каналы");
|
reply.Keywords.Add("telegram каналы");
|
||||||
service.GenerateKeywordsReply = reply;
|
service.GenerateKeywordsReply = reply;
|
||||||
FakeSettingsStore settings = new();
|
TestSettingsStore settings = new();
|
||||||
ISecretCipher cipher = TestCiphers.New();
|
ISecretCipher cipher = TestCiphers.New();
|
||||||
FakeTenantLimitStore limits = new();
|
TestTenantLimitStore limits = new();
|
||||||
|
|
||||||
IAiTools tools = CreateTools(port, settings, cipher, limits);
|
IAiTools tools = CreateTools(port, settings, cipher, limits);
|
||||||
AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("Бэкенд-разработка на Python", CancellationToken.None);
|
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) =>
|
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
|
||||||
{
|
{
|
||||||
service.KeywordsUnavailable = true;
|
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);
|
AiGenerateKeywordsResultDto result = await tools.GenerateKeywordsAsync("описание", CancellationToken.None);
|
||||||
|
|
||||||
@@ -88,8 +88,8 @@ public sealed class GrpcAiToolsTests
|
|||||||
Reason = "другая сфера",
|
Reason = "другая сфера",
|
||||||
Usage = new Usage { Prompt = 200, Completion = 10, Total = 210 },
|
Usage = new Usage { Prompt = 200, Completion = 10, Total = 210 },
|
||||||
};
|
};
|
||||||
FakeTenantLimitStore limits = new();
|
TestTenantLimitStore limits = new();
|
||||||
IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New(), limits);
|
IAiTools tools = CreateTools(port, new TestSettingsStore(), TestCiphers.New(), limits);
|
||||||
|
|
||||||
AiEvaluateFitResultDto result = await tools.EvaluateFitAsync(
|
AiEvaluateFitResultDto result = await tools.EvaluateFitAsync(
|
||||||
"Ищу дизайнера для лендинга", "Разработка сайтов на Python", new[] { "python", "бэкенд" }, CancellationToken.None);
|
"Ищу дизайнера для лендинга", "Разработка сайтов на Python", new[] { "python", "бэкенд" }, CancellationToken.None);
|
||||||
@@ -113,7 +113,7 @@ public sealed class GrpcAiToolsTests
|
|||||||
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
|
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
|
||||||
{
|
{
|
||||||
service.FitUnavailable = true;
|
service.FitUnavailable = true;
|
||||||
IAiTools tools = CreateTools(port, new FakeSettingsStore(), TestCiphers.New());
|
IAiTools tools = CreateTools(port, new TestSettingsStore(), TestCiphers.New());
|
||||||
|
|
||||||
await Assert.ThrowsAsync<AiUnavailableException>(
|
await Assert.ThrowsAsync<AiUnavailableException>(
|
||||||
() => tools.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None));
|
() => tools.EvaluateFitAsync("текст", "описание", new[] { "ключ" }, CancellationToken.None));
|
||||||
@@ -128,18 +128,19 @@ public sealed class GrpcAiToolsTests
|
|||||||
// Возвращает: Экземпляр GrpcAiTools.
|
// Возвращает: Экземпляр GrpcAiTools.
|
||||||
private static GrpcAiTools CreateTools(
|
private static GrpcAiTools CreateTools(
|
||||||
int port,
|
int port,
|
||||||
FakeSettingsStore settings,
|
TestSettingsStore settings,
|
||||||
ISecretCipher cipher,
|
ISecretCipher cipher,
|
||||||
FakeTenantLimitStore? limits = null)
|
TestTenantLimitStore? limits = null)
|
||||||
{
|
{
|
||||||
|
limits ??= new TestTenantLimitStore();
|
||||||
ITenantContext tenantContext = new TenantContext();
|
ITenantContext tenantContext = new TenantContext();
|
||||||
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
||||||
var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
|
var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
|
||||||
return new GrpcAiTools(
|
return new GrpcAiTools(
|
||||||
tenantContext,
|
tenantContext,
|
||||||
connection,
|
connection,
|
||||||
new AiProviderConfigBuilder(settings, cipher),
|
new AiProviderConfigBuilder(settings.Store, cipher),
|
||||||
new TokenUsageRecorder(settings, limits ?? new FakeTenantLimitStore(), tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
|
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
|
||||||
NullLogger<GrpcAiTools>.Instance);
|
NullLogger<GrpcAiTools>.Instance);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -110,10 +110,10 @@ public sealed class GrpcMlClientTests
|
|||||||
Eval = new ModelEval { Count = 5, Correct = 4, Accuracy = 0.8 },
|
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.MlDecisions, "7");
|
||||||
settings.Preload(SettingsKeys.AiDecisions, "3");
|
settings.Preload(SettingsKeys.AiDecisions, "3");
|
||||||
var learning = new FakeMlLearningStore { LearningCount = 9 };
|
var learning = new TestMlLearningStore { LearningCount = 9 };
|
||||||
|
|
||||||
IMlClient client = CreateClient(port, settings, learning);
|
IMlClient client = CreateClient(port, settings, learning);
|
||||||
MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None);
|
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) =>
|
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_1", "текст 1", "b_a", 1.0);
|
||||||
learning.SeedOutbox("mle_2", "текст 2", "spam", 1.0);
|
learning.SeedOutbox("mle_2", "текст 2", "spam", 1.0);
|
||||||
IMlClient client = CreateClient(port, learning: learning);
|
IMlClient client = CreateClient(port, learning: learning);
|
||||||
@@ -206,7 +206,7 @@ public sealed class GrpcMlClientTests
|
|||||||
|
|
||||||
Assert.True(reset.Ok);
|
Assert.True(reset.Ok);
|
||||||
Assert.Null(reset.Error);
|
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);
|
Assert.Equal(1, service.ResetCalls);
|
||||||
|
|
||||||
_ = await client.StatusAsync(CancellationToken.None);
|
_ = await client.StatusAsync(CancellationToken.None);
|
||||||
@@ -220,7 +220,7 @@ public sealed class GrpcMlClientTests
|
|||||||
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
||||||
{
|
{
|
||||||
service.ResetReply = new ResetReply { Ok = false, Error = "не удалось пересоздать файл модели" };
|
service.ResetReply = new ResetReply { Ok = false, Error = "не удалось пересоздать файл модели" };
|
||||||
var learning = new FakeMlLearningStore();
|
var learning = new TestMlLearningStore();
|
||||||
learning.SeedOutbox("mle_1", "текст", "b_a", 1.0);
|
learning.SeedOutbox("mle_1", "текст", "b_a", 1.0);
|
||||||
|
|
||||||
IMlClient client = CreateClient(port, learning: learning);
|
IMlClient client = CreateClient(port, learning: learning);
|
||||||
@@ -228,7 +228,7 @@ public sealed class GrpcMlClientTests
|
|||||||
|
|
||||||
Assert.False(reset.Ok);
|
Assert.False(reset.Ok);
|
||||||
Assert.Equal("не удалось пересоздать файл модели", reset.Error);
|
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) =>
|
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
||||||
{
|
{
|
||||||
service.ResetUnavailable = true;
|
service.ResetUnavailable = true;
|
||||||
var learning = new FakeMlLearningStore();
|
var learning = new TestMlLearningStore();
|
||||||
learning.SeedOutbox("mle_1", "текст", "spam", 1.0);
|
learning.SeedOutbox("mle_1", "текст", "spam", 1.0);
|
||||||
|
|
||||||
IMlClient client = CreateClient(port, learning: learning);
|
IMlClient client = CreateClient(port, learning: learning);
|
||||||
@@ -246,7 +246,7 @@ public sealed class GrpcMlClientTests
|
|||||||
|
|
||||||
Assert.False(reset.Ok);
|
Assert.False(reset.Ok);
|
||||||
Assert.Equal("ML-сервис недоступен", reset.Error);
|
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) =>
|
await MlGrpcTestHost.RunAsync(MlGrpcTestHost.DefaultToken, new RecordingMlService(), async (port, service) =>
|
||||||
{
|
{
|
||||||
var learning = new FakeMlLearningStore();
|
var learning = new TestMlLearningStore();
|
||||||
IMlClient client = CreateClient(port, learning: learning);
|
IMlClient client = CreateClient(port, learning: learning);
|
||||||
|
|
||||||
await client.PushAsync(" нужен python ", "b_junior", 1.0, CancellationToken.None);
|
await client.PushAsync(" нужен python ", "b_junior", 1.0, CancellationToken.None);
|
||||||
@@ -309,22 +309,24 @@ public sealed class GrpcMlClientTests
|
|||||||
// Возвращает: Экземпляр GrpcMlClient в tenant-контексте теста.
|
// Возвращает: Экземпляр GrpcMlClient в tenant-контексте теста.
|
||||||
private static GrpcMlClient CreateClient(
|
private static GrpcMlClient CreateClient(
|
||||||
int port,
|
int port,
|
||||||
FakeSettingsStore? settings = null,
|
TestSettingsStore? settings = null,
|
||||||
FakeMlLearningStore? learning = null,
|
TestMlLearningStore? learning = null,
|
||||||
MlStatusCache? cache = null)
|
MlStatusCache? cache = null)
|
||||||
{
|
{
|
||||||
|
settings ??= new TestSettingsStore();
|
||||||
|
learning ??= new TestMlLearningStore();
|
||||||
ITenantContext tenantContext = new TenantContext();
|
ITenantContext tenantContext = new TenantContext();
|
||||||
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
||||||
var options = new MlServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" };
|
var options = new MlServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" };
|
||||||
return new GrpcMlClient(
|
return new GrpcMlClient(
|
||||||
tenantContext,
|
tenantContext,
|
||||||
settings ?? new FakeSettingsStore(),
|
settings.Store,
|
||||||
learning ?? new FakeMlLearningStore(),
|
learning.Store,
|
||||||
new MlGrpcConnection(options),
|
new MlGrpcConnection(options),
|
||||||
cache ?? new MlStatusCache(),
|
cache ?? new MlStatusCache(),
|
||||||
new TokenUsageRecorder(
|
new TokenUsageRecorder(
|
||||||
settings ?? new FakeSettingsStore(),
|
settings.Store,
|
||||||
new FakeTenantLimitStore(),
|
new TestTenantLimitStore().Store,
|
||||||
tenantContext,
|
tenantContext,
|
||||||
new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
|
new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
|
||||||
NullLogger<GrpcMlClient>.Instance);
|
NullLogger<GrpcMlClient>.Instance);
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ using Deal.Modules.Tenants.Application.Abstractions;
|
|||||||
using Deal.Modules.Tenants.Application.Services;
|
using Deal.Modules.Tenants.Application.Services;
|
||||||
using Deal.SharedKernel.Tenants.Abstractions;
|
using Deal.SharedKernel.Tenants.Abstractions;
|
||||||
using Deal.SharedKernel.Tenants.Models;
|
using Deal.SharedKernel.Tenants.Models;
|
||||||
using Deal.Tests.Unit.Modules.Cards;
|
|
||||||
using Deal.Tests.Unit.Modules.Kanban;
|
using Deal.Tests.Unit.Modules.Kanban;
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
using Deal.Tests.Unit.Modules.Tenants;
|
using Deal.Tests.Unit.Modules.Tenants;
|
||||||
@@ -144,11 +143,11 @@ public sealed class IntegrationsDiTests
|
|||||||
ITenantContext tenantContext = new TenantContext();
|
ITenantContext tenantContext = new TenantContext();
|
||||||
tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N")));
|
tenantContext.SetTenant(new TenantId(Guid.NewGuid().ToString("N")));
|
||||||
services.AddSingleton<ITenantContext>(tenantContext);
|
services.AddSingleton<ITenantContext>(tenantContext);
|
||||||
services.AddScoped<ISettingsStore>(_ => new FakeSettingsStore());
|
services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store);
|
||||||
services.AddScoped<ISecretCipher>(_ => TestCiphers.New());
|
services.AddScoped<ISecretCipher>(_ => TestCiphers.New());
|
||||||
services.AddScoped<ICardStore>(_ => new FakeKanjStore());
|
services.AddScoped<ICardStore>(_ => new TestKanjStore().Store);
|
||||||
services.AddScoped<IMlLearningStore>(_ => new FakeMlLearningStore());
|
services.AddScoped<IMlLearningStore>(_ => new TestMlLearningStore().Store);
|
||||||
services.AddScoped<ITenantLimitStore>(_ => new FakeTenantLimitStore());
|
services.AddScoped<ITenantLimitStore>(_ => new TestTenantLimitStore().Store);
|
||||||
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
|
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
|
||||||
services.AddScoped<LocalFieldsParser>();
|
services.AddScoped<LocalFieldsParser>();
|
||||||
services.AddScoped<AiClassifyContextBuilder>();
|
services.AddScoped<AiClassifyContextBuilder>();
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Deal.Contracts.Integrations.Models;
|
|||||||
using Deal.Infrastructure.Integrations.Services;
|
using Deal.Infrastructure.Integrations.Services;
|
||||||
using Deal.Modules.Pipeline.Application.Parse;
|
using Deal.Modules.Pipeline.Application.Parse;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
|
using Deal.Tests.Unit.Support;
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Contracts;
|
namespace Deal.Tests.Unit.Contracts;
|
||||||
@@ -13,10 +14,10 @@ public sealed class LocalAiClassifierTests
|
|||||||
{
|
{
|
||||||
// Создаёт контекст теста: пустое KV-хранилище (дефолты маркеров) + адаптер.
|
// Создаёт контекст теста: пустое KV-хранилище (дефолты маркеров) + адаптер.
|
||||||
// Возвращает: Кортеж (KV-настройки, классификатор).
|
// Возвращает: Кортеж (KV-настройки, классификатор).
|
||||||
private static (FakeSettingsStore Settings, LocalAiClassifier Classifier) CreateContext()
|
private static (TestSettingsStore Settings, LocalAiClassifier Classifier) CreateContext()
|
||||||
{
|
{
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
var classifier = new LocalAiClassifier(new LocalFieldsParser(settings));
|
var classifier = new LocalAiClassifier(new LocalFieldsParser(settings.Store));
|
||||||
return (settings, classifier);
|
return (settings, classifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -140,7 +141,7 @@ public sealed class LocalAiClassifierTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Classify_CustomHireMarkersOverride_AreUsed()
|
public async Task Classify_CustomHireMarkersOverride_AreUsed()
|
||||||
{
|
{
|
||||||
(FakeSettingsStore settings, LocalAiClassifier classifier) = CreateContext();
|
(TestSettingsStore settings, LocalAiClassifier classifier) = CreateContext();
|
||||||
// Дефолтные маркеры заменены: «вакансия» больше не маркер найма, свой — маркер.
|
// Дефолтные маркеры заменены: «вакансия» больше не маркер найма, свой — маркер.
|
||||||
settings.Preload(SettingsKeys.HireMarkers, "[\"нанимаем джунов\"]");
|
settings.Preload(SettingsKeys.HireMarkers, "[\"нанимаем джунов\"]");
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ using Deal.Contracts.Integrations.Models;
|
|||||||
using Deal.Modules.Kanban.Application.Models;
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
using Deal.Modules.Pipeline.Application.Models;
|
using Deal.Modules.Pipeline.Application.Models;
|
||||||
using Deal.Modules.Pipeline.Application.Services;
|
using Deal.Modules.Pipeline.Application.Services;
|
||||||
using Deal.Tests.Unit.Modules.Cards;
|
|
||||||
using Deal.Tests.Unit.Modules.Kanban;
|
using Deal.Tests.Unit.Modules.Kanban;
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
using Deal.Tests.Unit.Support;
|
using Deal.Tests.Unit.Support;
|
||||||
@@ -18,9 +17,9 @@ public sealed class PipelineCardWriterTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreateCard_CreatesInboxCardAndLinksDedupClaim()
|
public async Task CreateCard_CreatesInboxCardAndLinksDedupClaim()
|
||||||
{
|
{
|
||||||
(PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create();
|
(PipelineCardWriter writer, TestKanjStore store, TestPipelineStore pipelineStore) = Create();
|
||||||
const string hash = "abcdef0123456789abcdef0123456789abcdef01";
|
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(
|
AiParsedCardDto parsed = Parsed(
|
||||||
title: "Python-разработчик на бота",
|
title: "Python-разработчик на бота",
|
||||||
budget: new AiBudgetDto(2000, 2000, "USD"),
|
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(new CardBudgetDto(2000, 2000, "USD"), card.Budget);
|
||||||
|
|
||||||
Assert.Equal(card.Id, pipelineStore.DedupLeadId(hash));
|
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]
|
[Fact]
|
||||||
public async Task CreateCard_AssignedBoardAccepted_WritesCardIntoBoardColumn()
|
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" });
|
store.SeedBoard(new ContainerDto { Id = "b_py" });
|
||||||
const string hash = "abcdef0123456789abcdef0123456789abcdef01";
|
const string hash = "abcdef0123456789abcdef0123456789abcdef01";
|
||||||
await pipelineStore.ClaimAsync(hash, CancellationToken.None);
|
await pipelineStore.Store.ClaimAsync(hash, CancellationToken.None);
|
||||||
|
|
||||||
CardDto card = await writer.CreateCardAsync(
|
CardDto card = await writer.CreateCardAsync(
|
||||||
Parsed(title: "Python-разработчик", board: "b_py"),
|
Parsed(title: "Python-разработчик", board: "b_py"),
|
||||||
@@ -66,10 +65,10 @@ public sealed class PipelineCardWriterTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreateCard_AddCardFails_ThrowsAndDoesNotLinkDedup()
|
public async Task CreateCard_AddCardFails_ThrowsAndDoesNotLinkDedup()
|
||||||
{
|
{
|
||||||
(PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create();
|
(PipelineCardWriter writer, TestKanjStore store, TestPipelineStore pipelineStore) = Create();
|
||||||
store.FailAddCard = true; // сбой адаптера записи (например, недоступна БД тенанта)
|
store.FailAddCard = true; // сбой адаптера записи (например, недоступна БД тенанта)
|
||||||
const string hash = "abcdef0123456789abcdef0123456789abcdef01";
|
const string hash = "abcdef0123456789abcdef0123456789abcdef01";
|
||||||
await pipelineStore.ClaimAsync(hash, CancellationToken.None);
|
await pipelineStore.Store.ClaimAsync(hash, CancellationToken.None);
|
||||||
|
|
||||||
await Assert.ThrowsAsync<InvalidOperationException>(
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
() => writer.CreateCardAsync(Parsed(title: "Python-разработчик"), Message("Текст"), hash, CancellationToken.None));
|
() => writer.CreateCardAsync(Parsed(title: "Python-разработчик"), Message("Текст"), hash, CancellationToken.None));
|
||||||
@@ -77,19 +76,19 @@ public sealed class PipelineCardWriterTests
|
|||||||
// Карточка не создана → связывать нечего: заявка дедупа осталась несвязанной (LeadId=null).
|
// Карточка не создана → связывать нечего: заявка дедупа осталась несвязанной (LeadId=null).
|
||||||
Assert.Empty(store.CardDtos);
|
Assert.Empty(store.CardDtos);
|
||||||
Assert.Null(pipelineStore.DedupLeadId(hash));
|
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 store = new TestKanjStore();
|
||||||
var pipelineStore = new FakePipelineStore();
|
var pipelineStore = new TestPipelineStore();
|
||||||
var composer = new CardComposer(store, new FakeSettingsStore());
|
var composer = new CardComposer(store.Store, new TestSettingsStore().Store);
|
||||||
return (new PipelineCardWriter(store, pipelineStore, composer), store, pipelineStore);
|
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.Modules.Tenants.Application.Services;
|
||||||
using Deal.SharedKernel.Tenants.Models;
|
using Deal.SharedKernel.Tenants.Models;
|
||||||
using Deal.Tests.Unit.Grpc;
|
using Deal.Tests.Unit.Grpc;
|
||||||
using Deal.Tests.Unit.Modules.Cards;
|
|
||||||
using Deal.Tests.Unit.Modules.Kanban;
|
using Deal.Tests.Unit.Modules.Kanban;
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
using Deal.Tests.Unit.Modules.Tenants;
|
using Deal.Tests.Unit.Modules.Tenants;
|
||||||
@@ -34,7 +33,7 @@ public sealed class PipelineWorkerGrpcAiTests
|
|||||||
// Id тенанта сценария строкой (формат N) — metadata tenant-id вызовов ai-service.
|
// Id тенанта сценария строкой (формат N) — metadata tenant-id вызовов ai-service.
|
||||||
private const string TenantIdValue = "abcdefabcdefabcdefabcdefabcdefab";
|
private const string TenantIdValue = "abcdefabcdefabcdefabcdefabcdefab";
|
||||||
|
|
||||||
// Guid того же тенанта — ключ строки лимита в FakeTenantLimitStore (списание usage).
|
// Guid того же тенанта — ключ строки лимита в TestTenantLimitStore (списание usage).
|
||||||
private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue);
|
private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue);
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -127,7 +126,7 @@ public sealed class PipelineWorkerGrpcAiTests
|
|||||||
Usage = new Usage { Prompt = 2000, Completion = 400, Total = 2400 },
|
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);
|
limits.Preload(TenantGuid, budgetTokens: 1000, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, usedTokens: 1000);
|
||||||
Context ctx = CreateContext(port, limits, budgeted: true);
|
Context ctx = CreateContext(port, limits, budgeted: true);
|
||||||
ctx.PipelineStore.SeedQueue(QueueRowFiltered("p_1", "Нужен middle Python-разработчик в команду, оплата 2000$ в месяц"));
|
ctx.PipelineStore.SeedQueue(QueueRowFiltered("p_1", "Нужен middle Python-разработчик в команду, оплата 2000$ в месяц"));
|
||||||
@@ -154,49 +153,49 @@ public sealed class PipelineWorkerGrpcAiTests
|
|||||||
|
|
||||||
private sealed record Context(
|
private sealed record Context(
|
||||||
PipelineWorkerService Worker,
|
PipelineWorkerService Worker,
|
||||||
FakePipelineStore PipelineStore,
|
TestPipelineStore PipelineStore,
|
||||||
FakeKanjStore KanjStore,
|
TestKanjStore KanjStore,
|
||||||
FakeSettingsStore Settings,
|
TestSettingsStore Settings,
|
||||||
FakeTenantLimitStore Limits);
|
TestTenantLimitStore Limits);
|
||||||
|
|
||||||
private static Context CreateContext(
|
private static Context CreateContext(
|
||||||
int port,
|
int port,
|
||||||
FakeTenantLimitStore? limits = null,
|
TestTenantLimitStore? limits = null,
|
||||||
bool budgeted = false)
|
bool budgeted = false)
|
||||||
{
|
{
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
var pipelineStore = new FakePipelineStore();
|
var pipelineStore = new TestPipelineStore();
|
||||||
var kanjStore = new FakeKanjStore();
|
var kanjStore = new TestKanjStore();
|
||||||
var mlClient = new FakeMlClient { Predict = NotReadyPrediction() };
|
var mlClient = new TestMlClient { Predict = NotReadyPrediction() };
|
||||||
var rules = new IncomingRules(settings);
|
var rules = new IncomingRules(settings.Store);
|
||||||
var fieldsParser = new LocalFieldsParser(settings);
|
var fieldsParser = new LocalFieldsParser(settings.Store);
|
||||||
|
|
||||||
ITenantContext tenantContext = new TenantContext();
|
ITenantContext tenantContext = new TenantContext();
|
||||||
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
||||||
var connection = new AiGrpcConnection(
|
var connection = new AiGrpcConnection(
|
||||||
new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
|
new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
|
||||||
limits ??= new FakeTenantLimitStore();
|
limits ??= new TestTenantLimitStore();
|
||||||
var grpcClassifier = new GrpcAiClassifier(
|
var grpcClassifier = new GrpcAiClassifier(
|
||||||
tenantContext,
|
tenantContext,
|
||||||
connection,
|
connection,
|
||||||
new AiProviderConfigBuilder(settings, TestCiphers.New()),
|
new AiProviderConfigBuilder(settings.Store, TestCiphers.New()),
|
||||||
new AiClassifyContextBuilder(settings, kanjStore),
|
new AiClassifyContextBuilder(settings.Store, kanjStore.Store),
|
||||||
new TokenUsageRecorder(settings, limits, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
|
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
|
||||||
NullLogger<GrpcAiClassifier>.Instance);
|
NullLogger<GrpcAiClassifier>.Instance);
|
||||||
IAiClassifier aiClassifier = budgeted
|
IAiClassifier aiClassifier = budgeted
|
||||||
? new BudgetedAiClassifier(
|
? new BudgetedAiClassifier(
|
||||||
grpcClassifier,
|
grpcClassifier,
|
||||||
new LocalAiClassifier(fieldsParser),
|
new LocalAiClassifier(fieldsParser),
|
||||||
limits,
|
limits.Store,
|
||||||
tenantContext,
|
tenantContext,
|
||||||
NullLogger<BudgetedAiClassifier>.Instance)
|
NullLogger<BudgetedAiClassifier>.Instance)
|
||||||
: grpcClassifier;
|
: grpcClassifier;
|
||||||
|
|
||||||
var processing = new PipelineProcessingService(pipelineStore, mlClient, new PipelineIngestService(pipelineStore));
|
var processing = new PipelineProcessingService(pipelineStore.Store, mlClient.Client, new PipelineIngestService(pipelineStore.Store));
|
||||||
var composer = new CardComposer(kanjStore, settings);
|
var composer = new CardComposer(kanjStore.Store, settings.Store);
|
||||||
var writer = new PipelineCardWriter(kanjStore, pipelineStore, composer);
|
var writer = new PipelineCardWriter(kanjStore.Store, pipelineStore.Store, composer);
|
||||||
var worker = new PipelineWorkerService(
|
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);
|
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.Tests.Unit.Support;
|
||||||
using Deal.Modules.Kanban.Application.Services;
|
using Deal.Modules.Kanban.Application.Services;
|
||||||
using Deal.Tests.Unit.Contracts;
|
using Deal.Tests.Unit.Contracts;
|
||||||
using Deal.Tests.Unit.Modules.Cards;
|
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Infrastructure;
|
namespace Deal.Tests.Unit.Infrastructure;
|
||||||
@@ -21,7 +20,7 @@ public sealed class CardMoverTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Move_ToStage_RoutesToStageMoveAndResetsReminder()
|
public async Task Move_ToStage_RoutesToStageMoveAndResetsReminder()
|
||||||
{
|
{
|
||||||
(CardMover mover, FakeKanjStore store) = Create();
|
(CardMover mover, TestKanjStore store) = Create();
|
||||||
store.SeedCard(new CardDto
|
store.SeedCard(new CardDto
|
||||||
{
|
{
|
||||||
Id = "c_1",
|
Id = "c_1",
|
||||||
@@ -44,7 +43,7 @@ public sealed class CardMoverTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Move_ToBoard_RoutesToDashboard()
|
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.SeedBoard(new ContainerDto { Id = "b_py", Name = "Python" });
|
||||||
store.SeedCard(new CardDto { Id = "c_1", Col = KanbanColumns.Inbox, Content = new SourceContent { Text = "Нужен 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]
|
[Fact]
|
||||||
public async Task Move_UnknownTarget_ReturnsDashboardInvalidTargetError()
|
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 = "Текст" } });
|
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);
|
CardMoveResultDto result = await mover.MoveAsync("c_1", "b_ghost", UserMove, CancellationToken.None);
|
||||||
@@ -79,10 +78,10 @@ public sealed class CardMoverTests
|
|||||||
Assert.False(result.Exists);
|
Assert.False(result.Exists);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static (CardMover Mover, FakeKanjStore Store) Create()
|
private static (CardMover Mover, TestKanjStore Store) Create()
|
||||||
{
|
{
|
||||||
var store = new FakeKanjStore();
|
var store = new TestKanjStore();
|
||||||
var cardsService = new CardsService(store, new FakeSettingsStore(), new FakeMlClient(), new TestFileStorage().Storage);
|
var cardsService = new CardsService(store.Store, new TestSettingsStore().Store, new TestMlClient().Client, new TestFileStorage().Storage);
|
||||||
return (new CardMover(cardsService), store);
|
return (new CardMover(cardsService), store);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -40,8 +40,8 @@ public sealed class DiscoveryBanGuardTests
|
|||||||
|
|
||||||
// Вчерашние вступления в суточную квоту не входят: 49 сегодня → ещё можно.
|
// Вчерашние вступления в суточную квоту не входят: 49 сегодня → ещё можно.
|
||||||
TestDiscoveryStore yesterdayStore = new(() => Now);
|
TestDiscoveryStore yesterdayStore = new(() => Now);
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
var secondGuard = new DiscoveryBanGuard(yesterdayStore.Store, settings, () => Now);
|
var secondGuard = new DiscoveryBanGuard(yesterdayStore.Store, settings.Store, () => Now);
|
||||||
for (int i = 0; i < 49; i++)
|
for (int i = 0; i < 49; i++)
|
||||||
{
|
{
|
||||||
yesterdayStore.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddMinutes(i));
|
yesterdayStore.SeedLog(DiscoveryLogEvents.JoinAuto, TodayStart.AddMinutes(i));
|
||||||
@@ -67,7 +67,7 @@ public sealed class DiscoveryBanGuardTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task NoteFlood_BlocksAutoJoinsUntilEndOfDay()
|
public async Task NoteFlood_BlocksAutoJoinsUntilEndOfDay()
|
||||||
{
|
{
|
||||||
(DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create();
|
(DiscoveryBanGuard guard, _, TestSettingsStore settings) = Create();
|
||||||
|
|
||||||
await guard.NoteFloodAsync(CancellationToken.None);
|
await guard.NoteFloodAsync(CancellationToken.None);
|
||||||
|
|
||||||
@@ -79,7 +79,7 @@ public sealed class DiscoveryBanGuardTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task FloodYesterday_DoesNotBlockToday()
|
public async Task FloodYesterday_DoesNotBlockToday()
|
||||||
{
|
{
|
||||||
(DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create();
|
(DiscoveryBanGuard guard, _, TestSettingsStore settings) = Create();
|
||||||
settings.Preload(SettingsKeys.DiscFloodDay, TodayStart.AddDays(-1).ToUnixTimeMilliseconds().ToString());
|
settings.Preload(SettingsKeys.DiscFloodDay, TodayStart.AddDays(-1).ToUnixTimeMilliseconds().ToString());
|
||||||
|
|
||||||
Assert.False(await guard.FloodTodayAsync(CancellationToken.None));
|
Assert.False(await guard.FloodTodayAsync(CancellationToken.None));
|
||||||
@@ -89,7 +89,7 @@ public sealed class DiscoveryBanGuardTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GlobalPaused_BlocksAutoJoins()
|
public async Task GlobalPaused_BlocksAutoJoins()
|
||||||
{
|
{
|
||||||
(DiscoveryBanGuard guard, _, FakeSettingsStore settings) = Create();
|
(DiscoveryBanGuard guard, _, TestSettingsStore settings) = Create();
|
||||||
settings.Preload(SettingsKeys.DiscPaused, "true");
|
settings.Preload(SettingsKeys.DiscPaused, "true");
|
||||||
|
|
||||||
Assert.True(await guard.GlobalPausedAsync(CancellationToken.None));
|
Assert.True(await guard.GlobalPausedAsync(CancellationToken.None));
|
||||||
@@ -99,7 +99,7 @@ public sealed class DiscoveryBanGuardTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CustomJoinLimit_AppliedWhenSettingStored()
|
public async Task CustomJoinLimit_AppliedWhenSettingStored()
|
||||||
{
|
{
|
||||||
(DiscoveryBanGuard guard, TestDiscoveryStore store, FakeSettingsStore settings) = Create();
|
(DiscoveryBanGuard guard, TestDiscoveryStore store, TestSettingsStore settings) = Create();
|
||||||
settings.Preload(SettingsKeys.DiscJoinLimit, "2");
|
settings.Preload(SettingsKeys.DiscJoinLimit, "2");
|
||||||
|
|
||||||
Assert.True(await guard.CanAutoJoinAsync(CancellationToken.None));
|
Assert.True(await guard.CanAutoJoinAsync(CancellationToken.None));
|
||||||
@@ -109,11 +109,11 @@ public sealed class DiscoveryBanGuardTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Создаёт связку гард + фейки на фиксированных «часах» (store и гард смотрят одно время).
|
// Создаёт связку гард + фейки на фиксированных «часах» (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 store = new TestDiscoveryStore(() => Now);
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
var guard = new DiscoveryBanGuard(store.Store, settings, () => Now);
|
var guard = new DiscoveryBanGuard(store.Store, settings.Store, () => Now);
|
||||||
return (guard, store, settings);
|
return (guard, store, settings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -117,7 +117,7 @@ public sealed class DiscoveryTasksServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Create_CustomThresholdAndSample_Respected()
|
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("discEvalThreshold", "60");
|
||||||
settings.Preload("discEvalSample", "20");
|
settings.Preload("discEvalSample", "20");
|
||||||
|
|
||||||
@@ -330,12 +330,12 @@ public sealed class DiscoveryTasksServiceTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Создаёт связку сервис + фейки (пустые настройки → дефолты SettingsDefaults).
|
// Создаёт связку сервис + фейки (пустые настройки → дефолты SettingsDefaults).
|
||||||
private static (DiscoveryTasksService Service, TestDiscoveryStore Store, FakeSettingsStore Settings) Create()
|
private static (DiscoveryTasksService Service, TestDiscoveryStore Store, TestSettingsStore Settings) Create()
|
||||||
{
|
{
|
||||||
var store = new TestDiscoveryStore();
|
var store = new TestDiscoveryStore();
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
var planGuard = new DiscoveryPlanGuard(store.Store, settings);
|
var planGuard = new DiscoveryPlanGuard(store.Store, settings.Store);
|
||||||
return (new DiscoveryTasksService(store.Store, planGuard, settings), store, settings);
|
return (new DiscoveryTasksService(store.Store, planGuard, settings.Store), store, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draft задачи с дефолтными полями (name/planJoins переопределяются).
|
// 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.Kanban.Application.Models;
|
||||||
using Deal.Modules.Pipeline.Application.Services;
|
using Deal.Modules.Pipeline.Application.Services;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
|
using Deal.Tests.Unit.Support;
|
||||||
using Deal.Modules.Settings.Application.Services;
|
using Deal.Modules.Settings.Application.Services;
|
||||||
using Deal.Tests.Unit.Modules.Cards;
|
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Modules.Kanban;
|
namespace Deal.Tests.Unit.Modules.Kanban;
|
||||||
@@ -24,11 +24,11 @@ public sealed class AiClassifyContextBuilderTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task BuildFilterPrompt_FillsDomainAndKeywords()
|
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.AiFilterPrompt, Json("Страж. Сфера: {domain}. Маркеры: {keywords}."));
|
||||||
settings.Preload(SettingsKeys.DomainDescription, Json(Domain));
|
settings.Preload(SettingsKeys.DomainDescription, Json(Domain));
|
||||||
settings.Preload(SettingsKeys.DomainKeywords, Json(Keywords));
|
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);
|
string prompt = await builder.BuildFilterPromptAsync(CancellationToken.None);
|
||||||
|
|
||||||
@@ -38,9 +38,9 @@ public sealed class AiClassifyContextBuilderTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task BuildFilterPrompt_NoDomain_UsesFallbackTextAndHint()
|
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}"));
|
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);
|
string prompt = await builder.BuildFilterPromptAsync(CancellationToken.None);
|
||||||
|
|
||||||
@@ -52,12 +52,12 @@ public sealed class AiClassifyContextBuilderTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task BuildClassifySystemPrompt_AppendsCardPromptWhenSet()
|
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.AiPrompt, Json("Классифицируй {domain}."));
|
||||||
settings.Preload(SettingsKeys.CardPrompt, Json("Верни блок «О заявке» {keywords}."));
|
settings.Preload(SettingsKeys.CardPrompt, Json("Верни блок «О заявке» {keywords}."));
|
||||||
settings.Preload(SettingsKeys.DomainDescription, Json(Domain));
|
settings.Preload(SettingsKeys.DomainDescription, Json(Domain));
|
||||||
settings.Preload(SettingsKeys.DomainKeywords, Json(Keywords));
|
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);
|
string prompt = await builder.BuildClassifySystemPromptAsync(CancellationToken.None);
|
||||||
|
|
||||||
@@ -69,8 +69,8 @@ public sealed class AiClassifyContextBuilderTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task BuildClassifyUserContext_EmptyBoards_ShowsNoBoardsPhrase()
|
public async Task BuildClassifyUserContext_EmptyBoards_ShowsNoBoardsPhrase()
|
||||||
{
|
{
|
||||||
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
|
TestSettingsStore settings = Context(out TestKanjStore kanj);
|
||||||
var builder = new AiClassifyContextBuilder(settings, kanj);
|
var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store);
|
||||||
|
|
||||||
string context = await builder.BuildClassifyUserContextAsync("Нужен разработчик", CancellationToken.None);
|
string context = await builder.BuildClassifyUserContextAsync("Нужен разработчик", CancellationToken.None);
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ public sealed class AiClassifyContextBuilderTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task BuildClassifyUserContext_ListsBoardsWithKeywordsAndRules()
|
public async Task BuildClassifyUserContext_ListsBoardsWithKeywordsAndRules()
|
||||||
{
|
{
|
||||||
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
|
TestSettingsStore settings = Context(out TestKanjStore kanj);
|
||||||
kanj.SeedBoard(new ContainerDto
|
kanj.SeedBoard(new ContainerDto
|
||||||
{
|
{
|
||||||
Id = "b_py",
|
Id = "b_py",
|
||||||
@@ -113,7 +113,7 @@ public sealed class AiClassifyContextBuilderTests
|
|||||||
Order = 1,
|
Order = 1,
|
||||||
});
|
});
|
||||||
kanj.SeedBoard(new ContainerDto { Id = "b_sug", Name = "Предложение", Suggested = true, Order = 2 });
|
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);
|
string context = await builder.BuildClassifyUserContextAsync("Ищу Python-разработчика", CancellationToken.None);
|
||||||
|
|
||||||
@@ -128,10 +128,10 @@ public sealed class AiClassifyContextBuilderTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task BuildClassifyUserContext_IncludesMarkupExamplesNewestFirst()
|
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_1", "b_py", "Пример: middle python (старый)");
|
||||||
SeedCardWithMove(kanj, "l_2", "b_py", "Пример: senior go (свежий)");
|
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);
|
string context = await builder.BuildClassifyUserContextAsync("Ищу разработчика", CancellationToken.None);
|
||||||
|
|
||||||
@@ -144,8 +144,8 @@ public sealed class AiClassifyContextBuilderTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task BuildClassifyUserContext_LongMessage_TruncatesTo5000CodePoints()
|
public async Task BuildClassifyUserContext_LongMessage_TruncatesTo5000CodePoints()
|
||||||
{
|
{
|
||||||
FakeSettingsStore settings = Context(out FakeKanjStore kanj);
|
TestSettingsStore settings = Context(out TestKanjStore kanj);
|
||||||
var builder = new AiClassifyContextBuilder(settings, kanj);
|
var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store);
|
||||||
string text = new string('д', 6000);
|
string text = new string('д', 6000);
|
||||||
|
|
||||||
string context = await builder.BuildClassifyUserContextAsync(text, CancellationToken.None);
|
string context = await builder.BuildClassifyUserContextAsync(text, CancellationToken.None);
|
||||||
@@ -158,10 +158,10 @@ public sealed class AiClassifyContextBuilderTests
|
|||||||
// Создаёт контекст: пустое KV + пустой канбан-фейк.
|
// Создаёт контекст: пустое KV + пустой канбан-фейк.
|
||||||
// kanj: Канбан-фейк (доски/журнал примеров).
|
// kanj: Канбан-фейк (доски/журнал примеров).
|
||||||
// Возвращает: KV-хранилище тенанта.
|
// Возвращает: KV-хранилище тенанта.
|
||||||
private static FakeSettingsStore Context(out FakeKanjStore kanj)
|
private static TestSettingsStore Context(out TestKanjStore kanj)
|
||||||
{
|
{
|
||||||
kanj = new FakeKanjStore();
|
kanj = new TestKanjStore();
|
||||||
return new FakeSettingsStore();
|
return new TestSettingsStore();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Кладёт карточку с исходным текстом и запись журнала move на неё (источник few-shot-примера).
|
// Кладёт карточку с исходным текстом и запись журнала move на неё (источник few-shot-примера).
|
||||||
@@ -170,12 +170,12 @@ public sealed class AiClassifyContextBuilderTests
|
|||||||
// board: Колонка-назначение (доска).
|
// board: Колонка-назначение (доска).
|
||||||
// text: Исходный текст карточки (source_msg).
|
// text: Исходный текст карточки (source_msg).
|
||||||
private static void SeedCardWithMove(
|
private static void SeedCardWithMove(
|
||||||
FakeKanjStore kanj,
|
TestKanjStore kanj,
|
||||||
string cardId,
|
string cardId,
|
||||||
string board,
|
string board,
|
||||||
string text)
|
string text)
|
||||||
{
|
{
|
||||||
kanj.SeedCard(new CardDto { Id = cardId, Col = board, Content = new SourceContent { Text = 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.Kanban.Application.Services;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
using Deal.Tests.Unit.Contracts;
|
using Deal.Tests.Unit.Contracts;
|
||||||
using Deal.Tests.Unit.Modules.Cards;
|
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Modules.Kanban;
|
namespace Deal.Tests.Unit.Modules.Kanban;
|
||||||
@@ -19,7 +18,7 @@ public sealed class CardsServiceRemindersTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Set_RemindersDisabled_Returns400TextAndWritesNothing()
|
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: "Отложенный бот"));
|
store.SeedCard(Card("c_1", stage: "hold", title: "Отложенный бот"));
|
||||||
|
|
||||||
CardResultDto result = await service.SetReminderAsync("c_1", NowMs() + DayMs, CancellationToken.None);
|
CardResultDto result = await service.SetReminderAsync("c_1", NowMs() + DayMs, CancellationToken.None);
|
||||||
@@ -32,7 +31,7 @@ public sealed class CardsServiceRemindersTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Set_Enabled_SetsReminderAndReturnsCard()
|
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));
|
store.SeedCard(Card("c_1", stage: "hold", title: "Отложенный бот", updatedAtMs: 1));
|
||||||
long atMs = NowMs() + DayMs;
|
long atMs = NowMs() + DayMs;
|
||||||
|
|
||||||
@@ -61,7 +60,7 @@ public sealed class CardsServiceRemindersTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Set_StageNotHold_Allowed()
|
public async Task Set_StageNotHold_Allowed()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _) = Create();
|
(CardsService service, TestKanjStore store, _) = Create();
|
||||||
store.SeedCard(Card("c_1", stage: "work", title: "В работе"));
|
store.SeedCard(Card("c_1", stage: "work", title: "В работе"));
|
||||||
long atMs = NowMs() + DayMs;
|
long atMs = NowMs() + DayMs;
|
||||||
|
|
||||||
@@ -76,7 +75,7 @@ public sealed class CardsServiceRemindersTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Clear_WithReminder_ClearsItAndReturnsTrue()
|
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) });
|
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) });
|
||||||
|
|
||||||
bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None);
|
bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None);
|
||||||
@@ -98,7 +97,7 @@ public sealed class CardsServiceRemindersTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Clear_RemindersDisabled_StillClears()
|
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) });
|
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() + DayMs) });
|
||||||
|
|
||||||
bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None);
|
bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None);
|
||||||
@@ -111,7 +110,7 @@ public sealed class CardsServiceRemindersTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Snooze_MovesReminderToNowPlus24h()
|
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) });
|
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) });
|
||||||
long beforeMs = NowMs();
|
long beforeMs = NowMs();
|
||||||
|
|
||||||
@@ -136,7 +135,7 @@ public sealed class CardsServiceRemindersTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Snooze_RemindersDisabled_StillSnoozes()
|
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) });
|
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) });
|
||||||
|
|
||||||
bool snoozed = await service.SnoozeReminderAsync("c_1", CancellationToken.None);
|
bool snoozed = await service.SnoozeReminderAsync("c_1", CancellationToken.None);
|
||||||
@@ -149,7 +148,7 @@ public sealed class CardsServiceRemindersTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CheckDue_Disabled_ClearsExpiredAndReturnsEmpty()
|
public async Task CheckDue_Disabled_ClearsExpiredAndReturnsEmpty()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false);
|
(CardsService service, TestKanjStore store, _) = Create(remindersEnabled: false);
|
||||||
long pastMs = NowMs() - 1;
|
long pastMs = NowMs() - 1;
|
||||||
store.SeedCard(Card("c_hold_past", stage: "hold") with { Reminder = new CardReminderDto(pastMs) });
|
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) });
|
store.SeedCard(Card("c_work_past", stage: "work") with { Reminder = new CardReminderDto(pastMs) });
|
||||||
@@ -167,7 +166,7 @@ public sealed class CardsServiceRemindersTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CheckDue_Enabled_ReturnsDueAndMarksFired()
|
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_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_d2", stage: "hold", title: "Поздний") with { Reminder = new CardReminderDto(NowMs() - 1) });
|
||||||
store.SeedCard(Card("c_future", stage: "hold", title: "Будущий") with { Reminder = new CardReminderDto(NowMs() + DayMs) });
|
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)));
|
due.Select(item => (item.Id, item.Title)));
|
||||||
Assert.All(due, item => Assert.Equal("hold", item.ContainerId));
|
Assert.All(due, item => Assert.Equal("hold", item.ContainerId));
|
||||||
// «Выстрелившие» помечены fired: повторная выборка due пуста (признак держит строка БД).
|
// «Выстрелившие» помечены 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 store = new TestKanjStore();
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
if (!remindersEnabled)
|
if (!remindersEnabled)
|
||||||
{
|
{
|
||||||
settings.Preload(SettingsKeys.RemindersEnabled, "false");
|
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) — для посева напоминаний в прошлом/будущем.
|
// Текущее время в 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.Models;
|
||||||
using Deal.Modules.Pipeline.Application.Services;
|
using Deal.Modules.Pipeline.Application.Services;
|
||||||
using Deal.Tests.Unit.Contracts;
|
using Deal.Tests.Unit.Contracts;
|
||||||
using Deal.Tests.Unit.Modules.Cards;
|
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
using Deal.Tests.Unit.Support;
|
using Deal.Tests.Unit.Support;
|
||||||
|
|
||||||
@@ -19,15 +18,15 @@ public sealed class MlReviewServiceTests
|
|||||||
private const string Dialog = "d_1";
|
private const string Dialog = "d_1";
|
||||||
|
|
||||||
private static MlReviewService Create(
|
private static MlReviewService Create(
|
||||||
FakePipelineStore pipeline,
|
TestPipelineStore pipeline,
|
||||||
FakeKanjStore kanj,
|
TestKanjStore kanj,
|
||||||
FakeMlClient ml,
|
TestMlClient ml,
|
||||||
out CardsService cards)
|
out CardsService cards)
|
||||||
{
|
{
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
cards = new CardsService(kanj, settings, ml, new TestFileStorage().Storage);
|
cards = new CardsService(kanj.Store, settings.Store, ml.Client, new TestFileStorage().Storage);
|
||||||
var processing = new PipelineProcessingService(pipeline, ml, new PipelineIngestService(pipeline));
|
var processing = new PipelineProcessingService(pipeline.Store, ml.Client, new PipelineIngestService(pipeline.Store));
|
||||||
return new MlReviewService(pipeline, kanj, cards, processing, ml);
|
return new MlReviewService(pipeline.Store, kanj.Store, cards, processing, ml.Client);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static QueueItemDto QueueRow(
|
private static QueueItemDto QueueRow(
|
||||||
@@ -73,12 +72,12 @@ public sealed class MlReviewServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Candidates_MergesQueueRejectedAndCardsWithVerdicts()
|
public async Task Candidates_MergesQueueRejectedAndCardsWithVerdicts()
|
||||||
{
|
{
|
||||||
var pipeline = new FakePipelineStore();
|
var pipeline = new TestPipelineStore();
|
||||||
pipeline.SeedQueue(QueueRow(101, "из очереди"));
|
pipeline.SeedQueue(QueueRow(101, "из очереди"));
|
||||||
pipeline.SeedRejected(RejectedRow(102, "из отсева"));
|
pipeline.SeedRejected(RejectedRow(102, "из отсева"));
|
||||||
var kanj = new FakeKanjStore();
|
var kanj = new TestKanjStore();
|
||||||
kanj.SeedCard(Card(103, "из карточки"));
|
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);
|
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None);
|
||||||
|
|
||||||
@@ -94,10 +93,10 @@ public sealed class MlReviewServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Candidates_FiltersByDialog()
|
public async Task Candidates_FiltersByDialog()
|
||||||
{
|
{
|
||||||
var pipeline = new FakePipelineStore();
|
var pipeline = new TestPipelineStore();
|
||||||
pipeline.SeedQueue(QueueRow(101, "нужный", Dialog));
|
pipeline.SeedQueue(QueueRow(101, "нужный", Dialog));
|
||||||
pipeline.SeedQueue(QueueRow(201, "другой", "d_2"));
|
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);
|
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None);
|
||||||
|
|
||||||
@@ -108,10 +107,10 @@ public sealed class MlReviewServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Candidates_NoDialog_ReturnsAllSources()
|
public async Task Candidates_NoDialog_ReturnsAllSources()
|
||||||
{
|
{
|
||||||
var pipeline = new FakePipelineStore();
|
var pipeline = new TestPipelineStore();
|
||||||
pipeline.SeedQueue(QueueRow(101, "a", "d_1"));
|
pipeline.SeedQueue(QueueRow(101, "a", "d_1"));
|
||||||
pipeline.SeedQueue(QueueRow(201, "b", "d_2"));
|
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);
|
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(null, 10, CancellationToken.None);
|
||||||
|
|
||||||
@@ -121,13 +120,13 @@ public sealed class MlReviewServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Candidates_ClampsLimitToMax()
|
public async Task Candidates_ClampsLimitToMax()
|
||||||
{
|
{
|
||||||
var pipeline = new FakePipelineStore();
|
var pipeline = new TestPipelineStore();
|
||||||
for (int i = 0; i < 5; i++)
|
for (int i = 0; i < 5; i++)
|
||||||
{
|
{
|
||||||
pipeline.SeedQueue(QueueRow(100 + i, $"текст {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);
|
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(Dialog, 2, CancellationToken.None);
|
||||||
|
|
||||||
@@ -137,10 +136,10 @@ public sealed class MlReviewServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Apply_Skip_DoesNotLearn()
|
public async Task Apply_Skip_DoesNotLearn()
|
||||||
{
|
{
|
||||||
var pipeline = new FakePipelineStore();
|
var pipeline = new TestPipelineStore();
|
||||||
pipeline.SeedQueue(QueueRow(101, "текст"));
|
pipeline.SeedQueue(QueueRow(101, "текст"));
|
||||||
var ml = new FakeMlClient();
|
var ml = new TestMlClient();
|
||||||
MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _);
|
MlReviewService service = Create(pipeline, new TestKanjStore(), ml, out _);
|
||||||
|
|
||||||
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSkip, CancellationToken.None);
|
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSkip, CancellationToken.None);
|
||||||
|
|
||||||
@@ -154,10 +153,10 @@ public sealed class MlReviewServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Apply_Spam_WithCard_TrashesAndLearns()
|
public async Task Apply_Spam_WithCard_TrashesAndLearns()
|
||||||
{
|
{
|
||||||
var kanj = new FakeKanjStore();
|
var kanj = new TestKanjStore();
|
||||||
kanj.SeedCard(Card(101, "спамный текст"));
|
kanj.SeedCard(Card(101, "спамный текст"));
|
||||||
var ml = new FakeMlClient();
|
var ml = new TestMlClient();
|
||||||
MlReviewService service = Create(new FakePipelineStore(), kanj, ml, out _);
|
MlReviewService service = Create(new TestPipelineStore(), kanj, ml, out _);
|
||||||
|
|
||||||
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None);
|
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None);
|
||||||
|
|
||||||
@@ -172,10 +171,10 @@ public sealed class MlReviewServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Apply_Spam_QueuedMessage_RejectsAndRemovesFromQueue()
|
public async Task Apply_Spam_QueuedMessage_RejectsAndRemovesFromQueue()
|
||||||
{
|
{
|
||||||
var pipeline = new FakePipelineStore();
|
var pipeline = new TestPipelineStore();
|
||||||
pipeline.SeedQueue(QueueRow(101, "рекламный текст"));
|
pipeline.SeedQueue(QueueRow(101, "рекламный текст"));
|
||||||
var ml = new FakeMlClient();
|
var ml = new TestMlClient();
|
||||||
MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _);
|
MlReviewService service = Create(pipeline, new TestKanjStore(), ml, out _);
|
||||||
|
|
||||||
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None);
|
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None);
|
||||||
|
|
||||||
@@ -192,7 +191,7 @@ public sealed class MlReviewServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Apply_Board_MovesCardAndLearns()
|
public async Task Apply_Board_MovesCardAndLearns()
|
||||||
{
|
{
|
||||||
var kanj = new FakeKanjStore();
|
var kanj = new TestKanjStore();
|
||||||
kanj.SeedBoard(new ContainerDto
|
kanj.SeedBoard(new ContainerDto
|
||||||
{
|
{
|
||||||
Id = "b_py",
|
Id = "b_py",
|
||||||
@@ -201,8 +200,8 @@ public sealed class MlReviewServiceTests
|
|||||||
Kind = ContainerKinds.Board,
|
Kind = ContainerKinds.Board,
|
||||||
});
|
});
|
||||||
kanj.SeedCard(Card(101, "python разработчик"));
|
kanj.SeedCard(Card(101, "python разработчик"));
|
||||||
var ml = new FakeMlClient();
|
var ml = new TestMlClient();
|
||||||
MlReviewService service = Create(new FakePipelineStore(), kanj, ml, out _);
|
MlReviewService service = Create(new TestPipelineStore(), kanj, ml, out _);
|
||||||
|
|
||||||
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:b_py", CancellationToken.None);
|
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:b_py", CancellationToken.None);
|
||||||
|
|
||||||
@@ -216,9 +215,9 @@ public sealed class MlReviewServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Apply_UnknownBoard_ReturnsError()
|
public async Task Apply_UnknownBoard_ReturnsError()
|
||||||
{
|
{
|
||||||
var kanj = new FakeKanjStore();
|
var kanj = new TestKanjStore();
|
||||||
kanj.SeedCard(Card(101, "текст"));
|
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);
|
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:missing", CancellationToken.None);
|
||||||
|
|
||||||
@@ -230,9 +229,9 @@ public sealed class MlReviewServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Apply_UnknownAction_ReturnsError()
|
public async Task Apply_UnknownAction_ReturnsError()
|
||||||
{
|
{
|
||||||
var pipeline = new FakePipelineStore();
|
var pipeline = new TestPipelineStore();
|
||||||
pipeline.SeedQueue(QueueRow(101, "текст"));
|
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);
|
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "что-то", CancellationToken.None);
|
||||||
|
|
||||||
@@ -244,7 +243,7 @@ public sealed class MlReviewServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Apply_MessageNotFound_ReturnsNull()
|
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);
|
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.Models;
|
||||||
using Deal.Modules.Kanban.Application.Services;
|
using Deal.Modules.Kanban.Application.Services;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
using Deal.Tests.Unit.Modules.Cards;
|
using Deal.Tests.Unit.Support;
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Modules.Kanban;
|
namespace Deal.Tests.Unit.Modules.Kanban;
|
||||||
@@ -15,7 +15,7 @@ public sealed class StorageTickServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Tick_DefaultSettings_ArchivesExpiredBoardAndInboxCards()
|
public async Task Tick_DefaultSettings_ArchivesExpiredBoardAndInboxCards()
|
||||||
{
|
{
|
||||||
(StorageTickService service, FakeKanjStore store, _) = Create();
|
(StorageTickService service, TestKanjStore store, _) = Create();
|
||||||
store.SeedBoard(Board("b_1"));
|
store.SeedBoard(Board("b_1"));
|
||||||
store.SeedCard(Card("l_board_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(16))));
|
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 дн. — не кандидат
|
store.SeedCard(Card("l_board_young", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(13)))); // < 14 дн. — не кандидат
|
||||||
@@ -47,7 +47,7 @@ public sealed class StorageTickServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Tick_AutoArchiveSettingFalse_KeepsExpiredCardsInPlace()
|
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");
|
settings.Preload(SettingsKeys.AutoArchive, "false");
|
||||||
store.SeedBoard(Board("b_1"));
|
store.SeedBoard(Board("b_1"));
|
||||||
store.SeedCard(Card("l_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(30))));
|
store.SeedCard(Card("l_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(30))));
|
||||||
@@ -62,7 +62,7 @@ public sealed class StorageTickServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Tick_ArchiveAfterDays30_ArchivesOnlyCardsOlderThanConfiguredMonth()
|
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");
|
settings.Preload(SettingsKeys.ArchiveAfterDays, "30");
|
||||||
store.SeedBoard(Board("b_1"));
|
store.SeedBoard(Board("b_1"));
|
||||||
store.SeedCard(Card("l_older_month", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(32))));
|
store.SeedCard(Card("l_older_month", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(32))));
|
||||||
@@ -78,7 +78,7 @@ public sealed class StorageTickServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Tick_ArchiveAfterDays1_ArchivesTwoDayOldCard()
|
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");
|
settings.Preload(SettingsKeys.ArchiveAfterDays, "1");
|
||||||
store.SeedCard(Card("l_inbox_old", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromDays(2))));
|
store.SeedCard(Card("l_inbox_old", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromDays(2))));
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ public sealed class StorageTickServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Tick_SecondRun_DoesNotReArchiveNorPurgeJustArchivedCards()
|
public async Task Tick_SecondRun_DoesNotReArchiveNorPurgeJustArchivedCards()
|
||||||
{
|
{
|
||||||
(StorageTickService service, FakeKanjStore store, _) = Create();
|
(StorageTickService service, TestKanjStore store, _) = Create();
|
||||||
store.SeedBoard(Board("b_1"));
|
store.SeedBoard(Board("b_1"));
|
||||||
store.SeedCard(Card("l_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(20))));
|
store.SeedCard(Card("l_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(20))));
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ public sealed class StorageTickServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Tick_PurgesOnlyArchiveCardsWithArchivedAtOlderThanArchiveClearDays()
|
public async Task Tick_PurgesOnlyArchiveCardsWithArchivedAtOlderThanArchiveClearDays()
|
||||||
{
|
{
|
||||||
(StorageTickService service, FakeKanjStore store, _) = Create();
|
(StorageTickService service, TestKanjStore store, _) = Create();
|
||||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||||
store.SeedCard(Card("l_expired", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(30))));
|
store.SeedCard(Card("l_expired", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(30))));
|
||||||
store.SetArchivedAt("l_expired", now.AddDays(-100)); // старше 90 дн.
|
store.SetArchivedAt("l_expired", now.AddDays(-100)); // старше 90 дн.
|
||||||
@@ -129,7 +129,7 @@ public sealed class StorageTickServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Tick_ArchiveClearDaysFromSettings_ControlsArchivePurgeBoundary()
|
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");
|
settings.Preload(SettingsKeys.ArchiveClearDays, "30");
|
||||||
DateTimeOffset now = DateTimeOffset.UtcNow;
|
DateTimeOffset now = DateTimeOffset.UtcNow;
|
||||||
store.SeedCard(Card("l_expired", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(10))));
|
store.SeedCard(Card("l_expired", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(10))));
|
||||||
@@ -148,7 +148,7 @@ public sealed class StorageTickServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Tick_PurgesOnlyTrashCardsWithReceivedAtOlderThanTrashClearDays()
|
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_old", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(10)))); // старше 7 дн.
|
||||||
store.SeedCard(Card("l_fresh", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromHours(6))));
|
store.SeedCard(Card("l_fresh", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromHours(6))));
|
||||||
|
|
||||||
@@ -165,7 +165,7 @@ public sealed class StorageTickServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Tick_TrashClearDaysFromSettings_ControlsTrashPurgeBoundary()
|
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");
|
settings.Preload(SettingsKeys.TrashClearDays, "3");
|
||||||
store.SeedCard(Card("l_old", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(5)))); // старше 3 дн.
|
store.SeedCard(Card("l_old", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(5)))); // старше 3 дн.
|
||||||
store.SeedCard(Card("l_fresh", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(1))));
|
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 store = new TestKanjStore();
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
return (new StorageTickService(store, settings), store, settings);
|
return (new StorageTickService(store.Store, settings.Store), store, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Доска-колонка минимально (id/имя достаточно для списка кандидатов фейка).
|
// Доска-колонка минимально (id/имя достаточно для списка кандидатов фейка).
|
||||||
@@ -215,13 +215,13 @@ public sealed class StorageTickServiceTests
|
|||||||
private static long ReceivedAtMsAgo(TimeSpan age) => DateTimeOffset.UtcNow.Subtract(age).ToUnixTimeMilliseconds();
|
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;
|
store.CardDtos.Single(card => card.Id == cardId).Col;
|
||||||
|
|
||||||
// Флаг is_new карточки после тика.
|
// Флаг 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;
|
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;
|
store.CardDtos.Single(card => card.Id == cardId).MatchHits;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using Deal.Modules.Cards.Application.Sources;
|
using Deal.Modules.Cards.Application.Sources;
|
||||||
using Deal.Modules.Pipeline.Application.Models;
|
using Deal.Modules.Pipeline.Application.Models;
|
||||||
using Deal.Modules.Pipeline.Application.Services;
|
using Deal.Modules.Pipeline.Application.Services;
|
||||||
using Deal.Tests.Unit.Modules.Kanban;
|
|
||||||
using Deal.Tests.Unit.Support;
|
using Deal.Tests.Unit.Support;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Modules.Pipeline;
|
namespace Deal.Tests.Unit.Modules.Pipeline;
|
||||||
@@ -16,8 +15,8 @@ public sealed class PipelineIngestServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task EnqueueAsync_TrimsTextAndWritesNewRowWithPId()
|
public async Task EnqueueAsync_TrimsTextAndWritesNewRowWithPId()
|
||||||
{
|
{
|
||||||
FakePipelineStore store = new();
|
TestPipelineStore store = new();
|
||||||
PipelineIngestService service = new(store);
|
PipelineIngestService service = new(store.Store);
|
||||||
|
|
||||||
PipelineIngestResultDto result = await service.EnqueueAsync(
|
PipelineIngestResultDto result = await service.EnqueueAsync(
|
||||||
Message(
|
Message(
|
||||||
@@ -48,8 +47,8 @@ public sealed class PipelineIngestServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task EnqueueAsync_MsgAtAbsent_ZeroMsgAtAndQueuedNow()
|
public async Task EnqueueAsync_MsgAtAbsent_ZeroMsgAtAndQueuedNow()
|
||||||
{
|
{
|
||||||
FakePipelineStore store = new();
|
TestPipelineStore store = new();
|
||||||
PipelineIngestService service = new(store);
|
PipelineIngestService service = new(store.Store);
|
||||||
|
|
||||||
await service.EnqueueAsync(
|
await service.EnqueueAsync(
|
||||||
Message("Сообщение без времени", receivedAt: default(DateTimeOffset)),
|
Message("Сообщение без времени", receivedAt: default(DateTimeOffset)),
|
||||||
@@ -63,8 +62,8 @@ public sealed class PipelineIngestServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task EnqueueAsync_ForceFlag_PropagatedToQueueRow()
|
public async Task EnqueueAsync_ForceFlag_PropagatedToQueueRow()
|
||||||
{
|
{
|
||||||
FakePipelineStore store = new();
|
TestPipelineStore store = new();
|
||||||
PipelineIngestService service = new(store);
|
PipelineIngestService service = new(store.Store);
|
||||||
|
|
||||||
await service.EnqueueAsync(Message("Возврат из отсева", force: true), CancellationToken.None);
|
await service.EnqueueAsync(Message("Возврат из отсева", force: true), CancellationToken.None);
|
||||||
|
|
||||||
@@ -76,8 +75,8 @@ public sealed class PipelineIngestServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task EnqueueAsync_EmptyOrWhitespaceText_Noop()
|
public async Task EnqueueAsync_EmptyOrWhitespaceText_Noop()
|
||||||
{
|
{
|
||||||
FakePipelineStore store = new();
|
TestPipelineStore store = new();
|
||||||
PipelineIngestService service = new(store);
|
PipelineIngestService service = new(store.Store);
|
||||||
|
|
||||||
PipelineIngestResultDto empty = await service.EnqueueAsync(Message(text: string.Empty), CancellationToken.None);
|
PipelineIngestResultDto empty = await service.EnqueueAsync(Message(text: string.Empty), CancellationToken.None);
|
||||||
PipelineIngestResultDto spaces = await service.EnqueueAsync(Message(text: " "), CancellationToken.None);
|
PipelineIngestResultDto spaces = await service.EnqueueAsync(Message(text: " "), CancellationToken.None);
|
||||||
@@ -93,8 +92,8 @@ public sealed class PipelineIngestServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task EnqueueAsync_SameSourceTwice_SecondIsDuplicate()
|
public async Task EnqueueAsync_SameSourceTwice_SecondIsDuplicate()
|
||||||
{
|
{
|
||||||
FakePipelineStore store = new();
|
TestPipelineStore store = new();
|
||||||
PipelineIngestService service = new(store);
|
PipelineIngestService service = new(store.Store);
|
||||||
QueuedMessage first = Message("Сообщение", origin: "d_1", externalId: "7");
|
QueuedMessage first = Message("Сообщение", origin: "d_1", externalId: "7");
|
||||||
QueuedMessage second = Message("Сообщение снова", origin: "d_1", externalId: "7");
|
QueuedMessage second = Message("Сообщение снова", origin: "d_1", externalId: "7");
|
||||||
|
|
||||||
@@ -111,8 +110,8 @@ public sealed class PipelineIngestServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task EnqueueAsync_SameSourceDifferentExternalIds_BothAccepted()
|
public async Task EnqueueAsync_SameSourceDifferentExternalIds_BothAccepted()
|
||||||
{
|
{
|
||||||
FakePipelineStore store = new();
|
TestPipelineStore store = new();
|
||||||
PipelineIngestService service = new(store);
|
PipelineIngestService service = new(store.Store);
|
||||||
|
|
||||||
await service.EnqueueAsync(Message("Первое", origin: "d_1", externalId: "1"), CancellationToken.None);
|
await service.EnqueueAsync(Message("Первое", origin: "d_1", externalId: "1"), CancellationToken.None);
|
||||||
PipelineIngestResultDto second = await service.EnqueueAsync(Message("Второе", origin: "d_1", externalId: "2"), CancellationToken.None);
|
PipelineIngestResultDto second = await service.EnqueueAsync(Message("Второе", origin: "d_1", externalId: "2"), CancellationToken.None);
|
||||||
@@ -124,8 +123,8 @@ public sealed class PipelineIngestServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task EnqueueAsync_SameOriginWithoutExternalId_SecondIsDuplicate()
|
public async Task EnqueueAsync_SameOriginWithoutExternalId_SecondIsDuplicate()
|
||||||
{
|
{
|
||||||
FakePipelineStore store = new();
|
TestPipelineStore store = new();
|
||||||
PipelineIngestService service = new(store);
|
PipelineIngestService service = new(store.Store);
|
||||||
|
|
||||||
PipelineIngestResultDto first = await service.EnqueueAsync(
|
PipelineIngestResultDto first = await service.EnqueueAsync(
|
||||||
Message("Первое", origin: "d_1", externalId: null), CancellationToken.None);
|
Message("Первое", origin: "d_1", externalId: null), CancellationToken.None);
|
||||||
@@ -142,8 +141,8 @@ public sealed class PipelineIngestServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task EnqueueAsync_LongText_TruncatedTo6000CodePoints()
|
public async Task EnqueueAsync_LongText_TruncatedTo6000CodePoints()
|
||||||
{
|
{
|
||||||
FakePipelineStore store = new();
|
TestPipelineStore store = new();
|
||||||
PipelineIngestService service = new(store);
|
PipelineIngestService service = new(store.Store);
|
||||||
string longText = new string('а', 7_000);
|
string longText = new string('а', 7_000);
|
||||||
|
|
||||||
await service.EnqueueAsync(Message(longText), CancellationToken.None);
|
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.Models;
|
||||||
using Deal.Modules.Pipeline.Application.Services;
|
using Deal.Modules.Pipeline.Application.Services;
|
||||||
using Deal.Tests.Unit.Contracts;
|
using Deal.Tests.Unit.Contracts;
|
||||||
using Deal.Tests.Unit.Modules.Kanban;
|
|
||||||
using Deal.Tests.Unit.Support;
|
using Deal.Tests.Unit.Support;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Modules.Pipeline;
|
namespace Deal.Tests.Unit.Modules.Pipeline;
|
||||||
@@ -17,7 +16,7 @@ public sealed class PipelineProcessingServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task RejectAsync_WhitespaceText_Noop()
|
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);
|
await service.RejectAsync(new RejectRecord { Text = " ", DecidedBy = "stop", Stage = "length" }, CancellationToken.None);
|
||||||
|
|
||||||
@@ -27,7 +26,7 @@ public sealed class PipelineProcessingServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task RejectAsync_TruncatesFieldsDefaultsHueAndUsesDeterministicId()
|
public async Task RejectAsync_TruncatesFieldsDefaultsHueAndUsesDeterministicId()
|
||||||
{
|
{
|
||||||
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
|
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
|
||||||
|
|
||||||
await service.RejectAsync(new RejectRecord
|
await service.RejectAsync(new RejectRecord
|
||||||
{
|
{
|
||||||
@@ -53,7 +52,7 @@ public sealed class PipelineProcessingServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task RejectAsync_SameDialogAndMsgId_UpsertsSingleRow()
|
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: "length", reason: "короткое"), CancellationToken.None);
|
||||||
await service.RejectAsync(Reject("d_1", 7, stage: "stop", reason: "стоп-фраза «реклама»"), CancellationToken.None);
|
await service.RejectAsync(Reject("d_1", 7, stage: "stop", reason: "стоп-фраза «реклама»"), CancellationToken.None);
|
||||||
@@ -68,7 +67,7 @@ public sealed class PipelineProcessingServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task QueueCountsAsync_CountsNewAndFiltered()
|
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_1", PipelineQueueStatuses.New, queuedAtMs: 1));
|
||||||
store.SeedQueue(QueueItem("p_2", PipelineQueueStatuses.New, queuedAtMs: 2));
|
store.SeedQueue(QueueItem("p_2", PipelineQueueStatuses.New, queuedAtMs: 2));
|
||||||
store.SeedQueue(QueueItem("p_3", PipelineQueueStatuses.Filtered, queuedAtMs: 3));
|
store.SeedQueue(QueueItem("p_3", PipelineQueueStatuses.Filtered, queuedAtMs: 3));
|
||||||
@@ -83,7 +82,7 @@ public sealed class PipelineProcessingServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task StatsAsync_ReturnsQueueCountsAndRejectedCount()
|
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.SeedQueue(QueueItem("p_1", PipelineQueueStatuses.New, queuedAtMs: 1));
|
||||||
store.SeedRejected(Rejected("r_1"));
|
store.SeedRejected(Rejected("r_1"));
|
||||||
store.SeedRejected(Rejected("r_2"));
|
store.SeedRejected(Rejected("r_2"));
|
||||||
@@ -99,7 +98,7 @@ public sealed class PipelineProcessingServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ListQueueAsync_OrdersByQueuedAtAndClampsLimitToMinimumOne()
|
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_1", PipelineQueueStatuses.New, queuedAtMs: 30));
|
||||||
store.SeedQueue(QueueItem("p_2", PipelineQueueStatuses.Filtered, queuedAtMs: 10));
|
store.SeedQueue(QueueItem("p_2", PipelineQueueStatuses.Filtered, queuedAtMs: 10));
|
||||||
store.SeedQueue(QueueItem("p_3", PipelineQueueStatuses.New, queuedAtMs: 20));
|
store.SeedQueue(QueueItem("p_3", PipelineQueueStatuses.New, queuedAtMs: 20));
|
||||||
@@ -116,7 +115,7 @@ public sealed class PipelineProcessingServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ListRejectedAsync_NoQuery_NewestFirstWithOffsetAndLimit()
|
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_1", rejectedAtMs: 1_000));
|
||||||
store.SeedRejected(Rejected("r_2", rejectedAtMs: 2_000));
|
store.SeedRejected(Rejected("r_2", rejectedAtMs: 2_000));
|
||||||
store.SeedRejected(Rejected("r_3", rejectedAtMs: 3_000));
|
store.SeedRejected(Rejected("r_3", rejectedAtMs: 3_000));
|
||||||
@@ -132,7 +131,7 @@ public sealed class PipelineProcessingServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ListRejectedAsync_QueryMatchesTextReasonKwAndSourceName()
|
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_text", text: "Middle Python разработчик", rejectedAtMs: 1_000));
|
||||||
store.SeedRejected(Rejected("r_reason", reason: "стоп-фраза «реклама»", rejectedAtMs: 2_000));
|
store.SeedRejected(Rejected("r_reason", reason: "стоп-фраза «реклама»", rejectedAtMs: 2_000));
|
||||||
store.SeedRejected(Rejected("r_kw", kw: "спам", rejectedAtMs: 3_000));
|
store.SeedRejected(Rejected("r_kw", kw: "спам", rejectedAtMs: 3_000));
|
||||||
@@ -152,7 +151,7 @@ public sealed class PipelineProcessingServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ListRejectedAsync_QueryWithOffsetPagesOverCandidatesAndEchoesTotal()
|
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_1", text: "middle a", rejectedAtMs: 1_000));
|
||||||
store.SeedRejected(Rejected("r_2", text: "middle b", rejectedAtMs: 2_000));
|
store.SeedRejected(Rejected("r_2", text: "middle b", rejectedAtMs: 2_000));
|
||||||
store.SeedRejected(Rejected("r_3", text: "middle c", rejectedAtMs: 3_000));
|
store.SeedRejected(Rejected("r_3", text: "middle c", rejectedAtMs: 3_000));
|
||||||
@@ -168,7 +167,7 @@ public sealed class PipelineProcessingServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReturnAsync_RecordNotFound_ReturnsNull()
|
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);
|
RejectReturnResultDto? result = await service.ReturnAsync("r_missing", string.Empty, CancellationToken.None);
|
||||||
|
|
||||||
@@ -180,7 +179,7 @@ public sealed class PipelineProcessingServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReturnAsync_AlreadyReturned_Returns400()
|
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: "было"));
|
store.SeedRejected(Rejected("r_1", returned: true, returnReason: "было"));
|
||||||
|
|
||||||
RejectReturnResultDto? result = await service.ReturnAsync("r_1", "ещё раз", CancellationToken.None);
|
RejectReturnResultDto? result = await service.ReturnAsync("r_1", "ещё раз", CancellationToken.None);
|
||||||
@@ -195,7 +194,7 @@ public sealed class PipelineProcessingServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReturnAsync_DuplicateSource_Returns400()
|
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"));
|
store.SeedRejected(Rejected("r_1", decidedBy: "dup", stage: "dup"));
|
||||||
|
|
||||||
RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None);
|
RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None);
|
||||||
@@ -209,7 +208,7 @@ public sealed class PipelineProcessingServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReturnAsync_EmptyText_Returns400()
|
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: " "));
|
store.SeedRejected(Rejected("r_1", text: " "));
|
||||||
|
|
||||||
RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None);
|
RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None);
|
||||||
@@ -223,7 +222,7 @@ public sealed class PipelineProcessingServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReturnAsync_SpamStage_UnlearnsSpamMarksRecordAndEnqueuesForceRow()
|
public async Task ReturnAsync_SpamStage_UnlearnsSpamMarksRecordAndEnqueuesForceRow()
|
||||||
{
|
{
|
||||||
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
|
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
|
||||||
store.SeedRejected(Rejected(
|
store.SeedRejected(Rejected(
|
||||||
"r_1",
|
"r_1",
|
||||||
text: " Продвижение в каналах ",
|
text: " Продвижение в каналах ",
|
||||||
@@ -270,7 +269,7 @@ public sealed class PipelineProcessingServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReturnAsync_NonSpamStage_NoLearningPushButEnqueuesForceRow()
|
public async Task ReturnAsync_NonSpamStage_NoLearningPushButEnqueuesForceRow()
|
||||||
{
|
{
|
||||||
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
|
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
|
||||||
store.SeedRejected(Rejected(
|
store.SeedRejected(Rejected(
|
||||||
"r_1",
|
"r_1",
|
||||||
text: "Короткое сообщение",
|
text: "Короткое сообщение",
|
||||||
@@ -296,7 +295,7 @@ public sealed class PipelineProcessingServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReturnAsync_LongReason_TruncatedTo500CodePointsOnRecord()
|
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"));
|
store.SeedRejected(Rejected("r_1", text: "Текст", stage: "stale", decidedBy: "stale", origin: "d_1"));
|
||||||
|
|
||||||
await service.ReturnAsync("r_1", new string('п', 700), CancellationToken.None);
|
await service.ReturnAsync("r_1", new string('п', 700), CancellationToken.None);
|
||||||
@@ -309,7 +308,7 @@ public sealed class PipelineProcessingServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReturnAsync_NoSourceInfo_EnqueuesDirectlyWithDefaultHue()
|
public async Task ReturnAsync_NoSourceInfo_EnqueuesDirectlyWithDefaultHue()
|
||||||
{
|
{
|
||||||
(PipelineProcessingService service, FakePipelineStore store, FakeMlClient ml) = Create();
|
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
|
||||||
store.SeedRejected(Rejected(
|
store.SeedRejected(Rejected(
|
||||||
"r_old",
|
"r_old",
|
||||||
text: "Старая запись без ссылки на исходное",
|
text: "Старая запись без ссылки на исходное",
|
||||||
@@ -340,7 +339,7 @@ public sealed class PipelineProcessingServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteAsync_RemovesSingleRecord()
|
public async Task DeleteAsync_RemovesSingleRecord()
|
||||||
{
|
{
|
||||||
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
|
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
|
||||||
store.SeedRejected(Rejected("r_1"));
|
store.SeedRejected(Rejected("r_1"));
|
||||||
store.SeedRejected(Rejected("r_2"));
|
store.SeedRejected(Rejected("r_2"));
|
||||||
|
|
||||||
@@ -352,7 +351,7 @@ public sealed class PipelineProcessingServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ClearAsync_ReturnsCountAndEmptiesRejects()
|
public async Task ClearAsync_ReturnsCountAndEmptiesRejects()
|
||||||
{
|
{
|
||||||
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
|
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
|
||||||
store.SeedRejected(Rejected("r_1"));
|
store.SeedRejected(Rejected("r_1"));
|
||||||
store.SeedRejected(Rejected("r_2"));
|
store.SeedRejected(Rejected("r_2"));
|
||||||
store.SeedRejected(Rejected("r_3"));
|
store.SeedRejected(Rejected("r_3"));
|
||||||
@@ -368,7 +367,7 @@ public sealed class PipelineProcessingServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PurgeExpiredAsync_RemovesOnlyRowsOlderThanRetention()
|
public async Task PurgeExpiredAsync_RemovesOnlyRowsOlderThanRetention()
|
||||||
{
|
{
|
||||||
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
|
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
|
||||||
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
store.SeedRejected(Rejected("r_old", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds)));
|
store.SeedRejected(Rejected("r_old", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds)));
|
||||||
store.SeedRejected(Rejected("r_fresh", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(1).TotalMilliseconds)));
|
store.SeedRejected(Rejected("r_fresh", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(1).TotalMilliseconds)));
|
||||||
@@ -381,12 +380,12 @@ public sealed class PipelineProcessingServiceTests
|
|||||||
|
|
||||||
// ─── Помощники ──────────────────────────────────────────────────────────
|
// ─── Помощники ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Сервис на общих фейках: очередь/отсев и ML-клиент одного сценария.
|
// Сервис на общих подставках: очередь/отсев и ML-клиент одного сценария.
|
||||||
private static (PipelineProcessingService Service, FakePipelineStore Store, FakeMlClient Ml) Create()
|
private static (PipelineProcessingService Service, TestPipelineStore Store, TestMlClient Ml) Create()
|
||||||
{
|
{
|
||||||
FakePipelineStore store = new();
|
TestPipelineStore store = new();
|
||||||
FakeMlClient ml = new();
|
TestMlClient ml = new();
|
||||||
PipelineProcessingService service = new(store, ml, new PipelineIngestService(store));
|
PipelineProcessingService service = new(store.Store, ml.Client, new PipelineIngestService(store.Store));
|
||||||
return (service, store, ml);
|
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.Abstractions;
|
||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
using Deal.Modules.Tenants.Application.Services;
|
using Deal.Modules.Tenants.Application.Services;
|
||||||
|
using Deal.Tests.Unit.Support;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Modules.Tenants;
|
namespace Deal.Tests.Unit.Modules.Tenants;
|
||||||
|
|
||||||
@@ -14,8 +15,8 @@ public sealed class AuditServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AppendAsync_SetsAtToUtcNow_AndSavesAllFields()
|
public async Task AppendAsync_SetsAtToUtcNow_AndSavesAllFields()
|
||||||
{
|
{
|
||||||
var store = new FakeAuditLogStore();
|
var store = new TestAuditLogStore();
|
||||||
var service = new AuditService(store);
|
var service = new AuditService(store.Store);
|
||||||
var actorId = Guid.NewGuid();
|
var actorId = Guid.NewGuid();
|
||||||
var tenantId = Guid.NewGuid();
|
var tenantId = Guid.NewGuid();
|
||||||
var before = DateTimeOffset.UtcNow;
|
var before = DateTimeOffset.UtcNow;
|
||||||
@@ -43,11 +44,11 @@ public sealed class AuditServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task QueryAsync_ReturnsFilteredRecordsNewestFirst()
|
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.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.TenantLoginFailed, AuditActorTypes.Tenant, tenantId: null, DateTimeOffset.UtcNow.AddMinutes(-1));
|
||||||
await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId: null, DateTimeOffset.UtcNow);
|
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);
|
var all = await service.QueryAsync(EmptyFilter(limit: 100), CancellationToken.None);
|
||||||
Assert.Equal(3, all.Count);
|
Assert.Equal(3, all.Count);
|
||||||
@@ -71,7 +72,7 @@ public sealed class AuditServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task QueryAsync_AppliesAtRangeAndTenantFilters()
|
public async Task QueryAsync_AppliesAtRangeAndTenantFilters()
|
||||||
{
|
{
|
||||||
var store = new FakeAuditLogStore();
|
var store = new TestAuditLogStore();
|
||||||
var tenantId = Guid.NewGuid();
|
var tenantId = Guid.NewGuid();
|
||||||
var outsideTenantId = Guid.NewGuid();
|
var outsideTenantId = Guid.NewGuid();
|
||||||
var from = DateTimeOffset.UtcNow.AddMinutes(-10);
|
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.AddMinutes(-1)); // вне окна
|
||||||
await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId, from);
|
await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, tenantId, from);
|
||||||
await AppendWithAtAsync(store, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, outsideTenantId, to);
|
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(
|
var byRange = await service.QueryAsync(
|
||||||
new AuditQueryDto(AuditEvents.TenantLoginOk, null, null, from, to, 100), CancellationToken.None);
|
new AuditQueryDto(AuditEvents.TenantLoginOk, null, null, from, to, 100), CancellationToken.None);
|
||||||
@@ -94,13 +95,13 @@ public sealed class AuditServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CountAsync_CountsAllRowsMatchingFilterRegardlessOfLimit()
|
public async Task CountAsync_CountsAllRowsMatchingFilterRegardlessOfLimit()
|
||||||
{
|
{
|
||||||
var store = new FakeAuditLogStore();
|
var store = new TestAuditLogStore();
|
||||||
for (int i = 0; i < 7; i++)
|
for (int i = 0; i < 7; i++)
|
||||||
{
|
{
|
||||||
await AppendWithAtAsync(store, AuditEvents.OperatorLoginOk, AuditActorTypes.Operator, tenantId: null, DateTimeOffset.UtcNow);
|
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);
|
var filter = new AuditQueryDto(AuditEvents.OperatorLoginOk, null, null, null, null, Limit: 2);
|
||||||
|
|
||||||
Assert.Equal(7, await service.CountAsync(filter, CancellationToken.None));
|
Assert.Equal(7, await service.CountAsync(filter, CancellationToken.None));
|
||||||
@@ -163,13 +164,13 @@ public sealed class AuditServiceTests
|
|||||||
|
|
||||||
// Добавляет запись в фейк с явным At/актором (минуя сервис — для проверок сортировки/фильтров).
|
// Добавляет запись в фейк с явным At/актором (минуя сервис — для проверок сортировки/фильтров).
|
||||||
private static async Task AppendWithAtAsync(
|
private static async Task AppendWithAtAsync(
|
||||||
FakeAuditLogStore store,
|
TestAuditLogStore store,
|
||||||
string eventType,
|
string eventType,
|
||||||
string actorType,
|
string actorType,
|
||||||
Guid? tenantId,
|
Guid? tenantId,
|
||||||
DateTimeOffset at)
|
DateTimeOffset at)
|
||||||
{
|
{
|
||||||
await store.AppendAsync(new AuditRecordDto(
|
await store.Store.AppendAsync(new AuditRecordDto(
|
||||||
eventType,
|
eventType,
|
||||||
actorType,
|
actorType,
|
||||||
ActorId: null,
|
ActorId: null,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ using Deal.Tests.Unit.Support;
|
|||||||
namespace Deal.Tests.Unit.Modules.Tenants;
|
namespace Deal.Tests.Unit.Modules.Tenants;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Юнит-тесты AuthService на fake-хранилище и детерминированном «хэшере».
|
/// Юнит-тесты AuthService на подставке хранилища и детерминированном «хэшере».
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class AuthServiceTests
|
public sealed class AuthServiceTests
|
||||||
{
|
{
|
||||||
@@ -17,17 +17,17 @@ public sealed class AuthServiceTests
|
|||||||
private static readonly Guid UserId = Guid.NewGuid();
|
private static readonly Guid UserId = Guid.NewGuid();
|
||||||
private static readonly Guid UserTenantId = Guid.NewGuid();
|
private static readonly Guid UserTenantId = Guid.NewGuid();
|
||||||
|
|
||||||
private readonly FakeAuthStore _store;
|
private readonly TestAuthStore _store;
|
||||||
private readonly TestTenantStore _tenantStore;
|
private readonly TestTenantStore _tenantStore;
|
||||||
private readonly AuthService _service;
|
private readonly AuthService _service;
|
||||||
|
|
||||||
public AuthServiceTests()
|
public AuthServiceTests()
|
||||||
{
|
{
|
||||||
var passwordHasher = TestHashers.New();
|
var passwordHasher = TestHashers.New();
|
||||||
_store = new FakeAuthStore();
|
_store = new TestAuthStore();
|
||||||
_tenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Тестовый тенант", TenantStatuses.Active, DateTimeOffset.UtcNow));
|
_tenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Тестовый тенант", TenantStatuses.Active, DateTimeOffset.UtcNow));
|
||||||
_store.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword)));
|
_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]
|
[Fact]
|
||||||
@@ -133,7 +133,7 @@ public sealed class AuthServiceTests
|
|||||||
var user = await _service.ResolveSessionAsync(rawToken, CancellationToken.None);
|
var user = await _service.ResolveSessionAsync(rawToken, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Null(user);
|
Assert.Null(user);
|
||||||
// Очистка «протухших» сессий вызвана и удалила сессию из fake-хранилища.
|
// Очистка «протухших» сессий вызвана и удалила сессию из хранилища.
|
||||||
Assert.Contains("delete-expired", _store.Calls);
|
Assert.Contains("delete-expired", _store.Calls);
|
||||||
Assert.Empty(_store.Sessions);
|
Assert.Empty(_store.Sessions);
|
||||||
}
|
}
|
||||||
@@ -204,10 +204,10 @@ public sealed class AuthServiceTests
|
|||||||
public async Task LoginAsync_WhenTenantSuspended_ReturnsSuspendedErrorWithoutSession()
|
public async Task LoginAsync_WhenTenantSuspended_ReturnsSuspendedErrorWithoutSession()
|
||||||
{
|
{
|
||||||
var passwordHasher = TestHashers.New();
|
var passwordHasher = TestHashers.New();
|
||||||
var suspendedStore = new FakeAuthStore();
|
var suspendedStore = new TestAuthStore();
|
||||||
suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword)));
|
suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword)));
|
||||||
var suspendedTenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Приостановленный", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
|
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);
|
var result = await suspendedService.LoginAsync(UserLogin, UserPassword, CancellationToken.None);
|
||||||
|
|
||||||
@@ -225,10 +225,10 @@ public sealed class AuthServiceTests
|
|||||||
// Порядок проверок AuthService: учётные данные раньше статуса тенанта — неверный пароль не раскрывает
|
// Порядок проверок AuthService: учётные данные раньше статуса тенанта — неверный пароль не раскрывает
|
||||||
// приостановку (тот же ответ, что для активного тенанта: Login/Token/Error пусты).
|
// приостановку (тот же ответ, что для активного тенанта: Login/Token/Error пусты).
|
||||||
var passwordHasher = TestHashers.New();
|
var passwordHasher = TestHashers.New();
|
||||||
var suspendedStore = new FakeAuthStore();
|
var suspendedStore = new TestAuthStore();
|
||||||
suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword)));
|
suspendedStore.AddUser(new StoredUserDto(UserId, UserLogin, UserTenantId, "active", passwordHasher.Hash(UserPassword)));
|
||||||
var suspendedTenantStore = new TestTenantStore(new TenantRecordDto(UserTenantId, "Приостановленный", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
|
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);
|
var result = await suspendedService.LoginAsync(UserLogin, "wrong-password", CancellationToken.None);
|
||||||
|
|
||||||
@@ -266,12 +266,12 @@ public sealed class AuthServiceTests
|
|||||||
public async Task ImpersonateAsync_WithoutLogin_TakesFirstTenantUser()
|
public async Task ImpersonateAsync_WithoutLogin_TakesFirstTenantUser()
|
||||||
{
|
{
|
||||||
var passwordHasher = TestHashers.New();
|
var passwordHasher = TestHashers.New();
|
||||||
var multiUserStore = new FakeAuthStore();
|
var multiUserStore = new TestAuthStore();
|
||||||
var firstUserId = Guid.NewGuid();
|
var firstUserId = Guid.NewGuid();
|
||||||
var secondUserId = Guid.NewGuid();
|
var secondUserId = Guid.NewGuid();
|
||||||
multiUserStore.AddUser(new StoredUserDto(firstUserId, "first@example.com", UserTenantId, "active", passwordHasher.Hash("p1")));
|
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")));
|
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-адаптере).
|
// login не задан — берётся первый пользователь тенанта (порядок хранилища = CreatedAt в EF-адаптере).
|
||||||
var result = await multiUserService.ImpersonateAsync(UserTenantId, targetLogin: null, Guid.NewGuid(), CancellationToken.None);
|
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 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")));
|
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);
|
var cross = await crossService.ImpersonateAsync(UserTenantId, "cross@example.com", Guid.NewGuid(), CancellationToken.None);
|
||||||
|
|
||||||
Assert.False(cross.Ok);
|
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));
|
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
|
||||||
var provisioner = new TestTenantProvisioner();
|
var provisioner = new TestTenantProvisioner();
|
||||||
var tenantStore = new TestTenantStore();
|
var tenantStore = new TestTenantStore();
|
||||||
var authStore = new FakeAuthStore();
|
var authStore = new TestAuthStore();
|
||||||
var passwordHasher = TestHashers.New();
|
var passwordHasher = TestHashers.New();
|
||||||
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, passwordHasher);
|
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, passwordHasher);
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ public sealed class JoinFlowTests
|
|||||||
var inviteStore = new TestInviteStore();
|
var inviteStore = new TestInviteStore();
|
||||||
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
|
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
|
||||||
var tenantStore = new TestTenantStore();
|
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);
|
var result = await service.ActivateAsync(Code, Email, name: " ", Password, CancellationToken.None);
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ public sealed class JoinFlowTests
|
|||||||
var provisioner = new TestTenantProvisioner();
|
var provisioner = new TestTenantProvisioner();
|
||||||
// Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему.
|
// Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему.
|
||||||
var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow));
|
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 service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
|
||||||
|
|
||||||
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
|
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
|
||||||
@@ -108,7 +108,7 @@ public sealed class JoinFlowTests
|
|||||||
var inviteStore = new TestInviteStore();
|
var inviteStore = new TestInviteStore();
|
||||||
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId));
|
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId));
|
||||||
var tenantStore = new TestTenantStore(); // целевого тенанта в реестре нет — «битый» инвайт
|
var tenantStore = new TestTenantStore(); // целевого тенанта в реестре нет — «битый» инвайт
|
||||||
var authStore = new FakeAuthStore();
|
var authStore = new TestAuthStore();
|
||||||
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
|
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
|
||||||
|
|
||||||
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
|
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
|
||||||
@@ -126,7 +126,7 @@ public sealed class JoinFlowTests
|
|||||||
var inviteStore = new TestInviteStore();
|
var inviteStore = new TestInviteStore();
|
||||||
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId));
|
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: tenantId));
|
||||||
var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Suspended", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
|
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 service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
|
||||||
|
|
||||||
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
|
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 inviteStore = new TestInviteStore();
|
||||||
var tenantStore = new TestTenantStore();
|
var tenantStore = new TestTenantStore();
|
||||||
var authStore = new FakeAuthStore();
|
var authStore = new TestAuthStore();
|
||||||
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
|
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
|
||||||
|
|
||||||
var result = await service.ActivateAsync(WrongCode, Email, name: null, Password, CancellationToken.None);
|
var result = await service.ActivateAsync(WrongCode, Email, name: null, Password, CancellationToken.None);
|
||||||
@@ -159,7 +159,7 @@ public sealed class JoinFlowTests
|
|||||||
var inviteStore = new TestInviteStore();
|
var inviteStore = new TestInviteStore();
|
||||||
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, expiresAt: DateTimeOffset.UtcNow.AddHours(-1)));
|
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, expiresAt: DateTimeOffset.UtcNow.AddHours(-1)));
|
||||||
var tenantStore = new TestTenantStore();
|
var tenantStore = new TestTenantStore();
|
||||||
var authStore = new FakeAuthStore();
|
var authStore = new TestAuthStore();
|
||||||
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
|
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
|
||||||
|
|
||||||
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
|
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
|
||||||
@@ -176,7 +176,7 @@ public sealed class JoinFlowTests
|
|||||||
{
|
{
|
||||||
var inviteStore = new TestInviteStore();
|
var inviteStore = new TestInviteStore();
|
||||||
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Activated));
|
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);
|
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ public sealed class JoinFlowTests
|
|||||||
{
|
{
|
||||||
var inviteStore = new TestInviteStore();
|
var inviteStore = new TestInviteStore();
|
||||||
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Revoked));
|
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);
|
var result = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
|
||||||
|
|
||||||
@@ -203,7 +203,7 @@ public sealed class JoinFlowTests
|
|||||||
var inviteStore = new TestInviteStore();
|
var inviteStore = new TestInviteStore();
|
||||||
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
|
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
|
||||||
var tenantStore = new TestTenantStore();
|
var tenantStore = new TestTenantStore();
|
||||||
var authStore = new FakeAuthStore();
|
var authStore = new TestAuthStore();
|
||||||
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
|
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
|
||||||
|
|
||||||
var result = await service.ActivateAsync(Code, OtherEmail, name: null, Password, CancellationToken.None);
|
var result = await service.ActivateAsync(Code, OtherEmail, name: null, Password, CancellationToken.None);
|
||||||
@@ -221,7 +221,7 @@ public sealed class JoinFlowTests
|
|||||||
var inviteStore = new TestInviteStore();
|
var inviteStore = new TestInviteStore();
|
||||||
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
|
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
|
||||||
var tenantStore = new TestTenantStore();
|
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"));
|
authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: StatusActive, "hash"));
|
||||||
var provisioner = new TestTenantProvisioner();
|
var provisioner = new TestTenantProvisioner();
|
||||||
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
|
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
|
||||||
@@ -248,7 +248,7 @@ public sealed class JoinFlowTests
|
|||||||
var inviteStore = new TestInviteStore();
|
var inviteStore = new TestInviteStore();
|
||||||
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
|
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
|
||||||
var tenantStore = new TestTenantStore();
|
var tenantStore = new TestTenantStore();
|
||||||
var authStore = new FakeAuthStore();
|
var authStore = new TestAuthStore();
|
||||||
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
|
var service = NewService(inviteStore, tenantStore, new TestTenantProvisioner().Provisioner, authStore, TestHashers.New());
|
||||||
|
|
||||||
var result = await service.ActivateAsync(Code, Email, name: null, password, CancellationToken.None);
|
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));
|
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null));
|
||||||
var provisioner = new TestTenantProvisioner();
|
var provisioner = new TestTenantProvisioner();
|
||||||
var tenantStore = new TestTenantStore();
|
var tenantStore = new TestTenantStore();
|
||||||
var authStore = new FakeAuthStore();
|
var authStore = new TestAuthStore();
|
||||||
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
|
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
|
||||||
|
|
||||||
JoinResultDto first = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
|
JoinResultDto first = await service.ActivateAsync(Code, Email, name: null, Password, CancellationToken.None);
|
||||||
@@ -288,7 +288,7 @@ public sealed class JoinFlowTests
|
|||||||
// Конкурент (отзыв) «успел» до нашего CAS — приглашение уже отозвано, активация не выполнится.
|
// Конкурент (отзыв) «успел» до нашего CAS — приглашение уже отозвано, активация не выполнится.
|
||||||
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Revoked));
|
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Revoked));
|
||||||
var tenantStore = new TestTenantStore();
|
var tenantStore = new TestTenantStore();
|
||||||
var authStore = new FakeAuthStore();
|
var authStore = new TestAuthStore();
|
||||||
var provisioner = new TestTenantProvisioner();
|
var provisioner = new TestTenantProvisioner();
|
||||||
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
|
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
|
||||||
|
|
||||||
@@ -311,7 +311,7 @@ public sealed class JoinFlowTests
|
|||||||
// Конкурентная активация «успела» до нашего CAS — приглашение уже активировано.
|
// Конкурентная активация «успела» до нашего CAS — приглашение уже активировано.
|
||||||
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Activated));
|
inviteStore.AddInvite(NewInvite(Code, Email, tenantId: null, status: InviteStatuses.Activated));
|
||||||
var tenantStore = new TestTenantStore();
|
var tenantStore = new TestTenantStore();
|
||||||
var authStore = new FakeAuthStore();
|
var authStore = new TestAuthStore();
|
||||||
var provisioner = new TestTenantProvisioner();
|
var provisioner = new TestTenantProvisioner();
|
||||||
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
|
var service = NewService(inviteStore, tenantStore, provisioner.Provisioner, authStore, TestHashers.New());
|
||||||
|
|
||||||
@@ -353,17 +353,17 @@ public sealed class JoinFlowTests
|
|||||||
|
|
||||||
// ─── Хелперы ─────────────────────────────────────────────────────────
|
// ─── Хелперы ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Собирает JoinService на общих фейк-хранилищах (все состояния — свежие).
|
// Собирает JoinService на общих подставках хранилищ (все состояния — свежие).
|
||||||
private static JoinService NewService(
|
private static JoinService NewService(
|
||||||
TestInviteStore inviteStore,
|
TestInviteStore inviteStore,
|
||||||
TestTenantStore tenantStore,
|
TestTenantStore tenantStore,
|
||||||
ITenantProvisioner provisioner,
|
ITenantProvisioner provisioner,
|
||||||
FakeAuthStore authStore,
|
TestAuthStore authStore,
|
||||||
IPasswordHasher passwordHasher) =>
|
IPasswordHasher passwordHasher) =>
|
||||||
new(
|
new(
|
||||||
new InvitesService(inviteStore.Store),
|
new InvitesService(inviteStore.Store),
|
||||||
new TenantService(tenantStore.Repository, provisioner),
|
new TenantService(tenantStore.Repository, provisioner),
|
||||||
authStore,
|
authStore.Store,
|
||||||
passwordHasher,
|
passwordHasher,
|
||||||
tenantStore.Repository);
|
tenantStore.Repository);
|
||||||
|
|
||||||
|
|||||||
@@ -14,15 +14,15 @@ public sealed class OperatorAuthServiceTests
|
|||||||
|
|
||||||
private static readonly Guid OperatorId = Guid.NewGuid();
|
private static readonly Guid OperatorId = Guid.NewGuid();
|
||||||
|
|
||||||
private readonly FakeOperatorAuthStore _store;
|
private readonly TestOperatorAuthStore _store;
|
||||||
private readonly OperatorAuthService _service;
|
private readonly OperatorAuthService _service;
|
||||||
|
|
||||||
public OperatorAuthServiceTests()
|
public OperatorAuthServiceTests()
|
||||||
{
|
{
|
||||||
var passwordHasher = TestHashers.New();
|
var passwordHasher = TestHashers.New();
|
||||||
_store = new FakeOperatorAuthStore();
|
_store = new TestOperatorAuthStore();
|
||||||
_store.AddOperator(new StoredOperatorDto(OperatorId, OperatorLogin, "active", passwordHasher.Hash(OperatorPassword)));
|
_store.AddOperator(new StoredOperatorDto(OperatorId, OperatorLogin, "active", passwordHasher.Hash(OperatorPassword)));
|
||||||
_service = new OperatorAuthService(_store, passwordHasher);
|
_service = new OperatorAuthService(_store.Store, passwordHasher);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -10,15 +10,15 @@ namespace Deal.Tests.Unit.Modules.Tenants;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class OperatorBootstrapServiceTests
|
public sealed class OperatorBootstrapServiceTests
|
||||||
{
|
{
|
||||||
private readonly FakeOperatorAuthStore _store;
|
private readonly TestOperatorAuthStore _store;
|
||||||
private readonly IPasswordHasher _passwordHasher;
|
private readonly IPasswordHasher _passwordHasher;
|
||||||
private readonly OperatorBootstrapService _service;
|
private readonly OperatorBootstrapService _service;
|
||||||
|
|
||||||
public OperatorBootstrapServiceTests()
|
public OperatorBootstrapServiceTests()
|
||||||
{
|
{
|
||||||
_passwordHasher = TestHashers.New();
|
_passwordHasher = TestHashers.New();
|
||||||
_store = new FakeOperatorAuthStore();
|
_store = new TestOperatorAuthStore();
|
||||||
_service = new OperatorBootstrapService(_store, _passwordHasher);
|
_service = new OperatorBootstrapService(_store.Store, _passwordHasher);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
using Deal.Modules.Tenants.Application.Services;
|
using Deal.Modules.Tenants.Application.Services;
|
||||||
|
using Deal.Tests.Unit.Support;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Modules.Tenants;
|
namespace Deal.Tests.Unit.Modules.Tenants;
|
||||||
|
|
||||||
@@ -14,7 +15,7 @@ public sealed class SuspiciousActivityServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AnalyzeAsync_NoRecords_ReturnsEmpty()
|
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);
|
SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None);
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ public sealed class SuspiciousActivityServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AnalyzeAsync_FailedLoginsPerIp_TriggersAtThreshold()
|
public async Task AnalyzeAsync_FailedLoginsPerIp_TriggersAtThreshold()
|
||||||
{
|
{
|
||||||
var store = new FakeAuditLogStore();
|
var store = new TestAuditLogStore();
|
||||||
for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerIpThreshold; i++)
|
for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerIpThreshold; i++)
|
||||||
{
|
{
|
||||||
SeedFailed(store, ip: "10.0.0.1", login: "user", minutesAgo: i);
|
SeedFailed(store, ip: "10.0.0.1", login: "user", minutesAgo: i);
|
||||||
@@ -46,7 +47,7 @@ public sealed class SuspiciousActivityServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AnalyzeAsync_FailedLoginsPerIp_DoubleThreshold_IsHigh()
|
public async Task AnalyzeAsync_FailedLoginsPerIp_DoubleThreshold_IsHigh()
|
||||||
{
|
{
|
||||||
var store = new FakeAuditLogStore();
|
var store = new TestAuditLogStore();
|
||||||
int count = SuspiciousActivityService.FailedLoginsPerIpThreshold * 2;
|
int count = SuspiciousActivityService.FailedLoginsPerIpThreshold * 2;
|
||||||
for (int i = 0; i < count; i++)
|
for (int i = 0; i < count; i++)
|
||||||
{
|
{
|
||||||
@@ -66,7 +67,7 @@ public sealed class SuspiciousActivityServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AnalyzeAsync_FailedLoginsPerLogin_Triggers()
|
public async Task AnalyzeAsync_FailedLoginsPerLogin_Triggers()
|
||||||
{
|
{
|
||||||
var store = new FakeAuditLogStore();
|
var store = new TestAuditLogStore();
|
||||||
for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerLoginThreshold; i++)
|
for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerLoginThreshold; i++)
|
||||||
{
|
{
|
||||||
SeedFailed(store, ip: $"10.0.0.{i + 1}", login: "target", minutesAgo: i);
|
SeedFailed(store, ip: $"10.0.0.{i + 1}", login: "target", minutesAgo: i);
|
||||||
@@ -84,11 +85,11 @@ public sealed class SuspiciousActivityServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AnalyzeAsync_ManyIpsPerActor_Triggers()
|
public async Task AnalyzeAsync_ManyIpsPerActor_Triggers()
|
||||||
{
|
{
|
||||||
var store = new FakeAuditLogStore();
|
var store = new TestAuditLogStore();
|
||||||
Guid actor = Guid.NewGuid();
|
Guid actor = Guid.NewGuid();
|
||||||
for (int i = 0; i < SuspiciousActivityService.DistinctIpsPerActorThreshold; i++)
|
for (int i = 0; i < SuspiciousActivityService.DistinctIpsPerActorThreshold; i++)
|
||||||
{
|
{
|
||||||
await store.AppendAsync(
|
await store.Store.AppendAsync(
|
||||||
new AuditRecordDto(
|
new AuditRecordDto(
|
||||||
AuditEvents.TenantLoginOk,
|
AuditEvents.TenantLoginOk,
|
||||||
AuditActorTypes.Tenant,
|
AuditActorTypes.Tenant,
|
||||||
@@ -113,10 +114,10 @@ public sealed class SuspiciousActivityServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AnalyzeAsync_AuthFailuresPerTenant_Triggers()
|
public async Task AnalyzeAsync_AuthFailuresPerTenant_Triggers()
|
||||||
{
|
{
|
||||||
var store = new FakeAuditLogStore();
|
var store = new TestAuditLogStore();
|
||||||
for (int i = 0; i < SuspiciousActivityService.AuthFailuresPerTenantThreshold; i++)
|
for (int i = 0; i < SuspiciousActivityService.AuthFailuresPerTenantThreshold; i++)
|
||||||
{
|
{
|
||||||
await store.AppendAsync(
|
await store.Store.AppendAsync(
|
||||||
new AuditRecordDto(
|
new AuditRecordDto(
|
||||||
AuditEvents.TenantLoginFailed,
|
AuditEvents.TenantLoginFailed,
|
||||||
AuditActorTypes.Tenant,
|
AuditActorTypes.Tenant,
|
||||||
@@ -140,7 +141,7 @@ public sealed class SuspiciousActivityServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AnalyzeAsync_DistinctLoginsPerIp_TriggersAtThreshold()
|
public async Task AnalyzeAsync_DistinctLoginsPerIp_TriggersAtThreshold()
|
||||||
{
|
{
|
||||||
var store = new FakeAuditLogStore();
|
var store = new TestAuditLogStore();
|
||||||
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++)
|
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++)
|
||||||
{
|
{
|
||||||
SeedFailed(store, ip: "10.3.0.1", login: $"user{i}", minutesAgo: i);
|
SeedFailed(store, ip: "10.3.0.1", login: $"user{i}", minutesAgo: i);
|
||||||
@@ -160,7 +161,7 @@ public sealed class SuspiciousActivityServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AnalyzeAsync_DistinctLoginsPerIp_DoubleThreshold_IsHigh()
|
public async Task AnalyzeAsync_DistinctLoginsPerIp_DoubleThreshold_IsHigh()
|
||||||
{
|
{
|
||||||
var store = new FakeAuditLogStore();
|
var store = new TestAuditLogStore();
|
||||||
int count = SuspiciousActivityService.DistinctLoginsPerIpThreshold * 2;
|
int count = SuspiciousActivityService.DistinctLoginsPerIpThreshold * 2;
|
||||||
for (int i = 0; i < count; i++)
|
for (int i = 0; i < count; i++)
|
||||||
{
|
{
|
||||||
@@ -180,7 +181,7 @@ public sealed class SuspiciousActivityServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AnalyzeAsync_DistinctLoginsPerIp_BelowThreshold_NoFinding()
|
public async Task AnalyzeAsync_DistinctLoginsPerIp_BelowThreshold_NoFinding()
|
||||||
{
|
{
|
||||||
var store = new FakeAuditLogStore();
|
var store = new TestAuditLogStore();
|
||||||
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold - 1; i++)
|
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold - 1; i++)
|
||||||
{
|
{
|
||||||
SeedFailed(store, ip: "10.3.0.3", login: $"user{i}", minutesAgo: i);
|
SeedFailed(store, ip: "10.3.0.3", login: $"user{i}", minutesAgo: i);
|
||||||
@@ -195,7 +196,7 @@ public sealed class SuspiciousActivityServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AnalyzeAsync_DistinctLoginsPerIp_SameLoginRepeated_NoFinding()
|
public async Task AnalyzeAsync_DistinctLoginsPerIp_SameLoginRepeated_NoFinding()
|
||||||
{
|
{
|
||||||
var store = new FakeAuditLogStore();
|
var store = new TestAuditLogStore();
|
||||||
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold + 1; i++)
|
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold + 1; i++)
|
||||||
{
|
{
|
||||||
SeedFailed(store, ip: "10.3.0.4", login: "repeated", minutesAgo: i);
|
SeedFailed(store, ip: "10.3.0.4", login: "repeated", minutesAgo: i);
|
||||||
@@ -210,7 +211,7 @@ public sealed class SuspiciousActivityServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AnalyzeAsync_DistinctLoginsPerIp_SuccessfulLoginsIgnored()
|
public async Task AnalyzeAsync_DistinctLoginsPerIp_SuccessfulLoginsIgnored()
|
||||||
{
|
{
|
||||||
var store = new FakeAuditLogStore();
|
var store = new TestAuditLogStore();
|
||||||
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++)
|
for (int i = 0; i < SuspiciousActivityService.DistinctLoginsPerIpThreshold; i++)
|
||||||
{
|
{
|
||||||
SeedSuccess(store, ip: "10.3.0.5", login: $"user{i}", minutesAgo: i);
|
SeedSuccess(store, ip: "10.3.0.5", login: $"user{i}", minutesAgo: i);
|
||||||
@@ -225,7 +226,7 @@ public sealed class SuspiciousActivityServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AnalyzeAsync_RecordsOutsideWindow_AreIgnored()
|
public async Task AnalyzeAsync_RecordsOutsideWindow_AreIgnored()
|
||||||
{
|
{
|
||||||
var store = new FakeAuditLogStore();
|
var store = new TestAuditLogStore();
|
||||||
for (int i = 0; i < 50; i++)
|
for (int i = 0; i < 50; i++)
|
||||||
{
|
{
|
||||||
SeedFailed(store, ip: "10.0.0.1", login: "user", minutesAgo: 60 * 30); // 30 часов назад — вне суток
|
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(
|
private static void SeedFailed(
|
||||||
FakeAuditLogStore store,
|
TestAuditLogStore store,
|
||||||
string ip,
|
string ip,
|
||||||
string login,
|
string login,
|
||||||
int minutesAgo)
|
int minutesAgo)
|
||||||
{
|
{
|
||||||
store.AppendAsync(
|
store.Store.AppendAsync(
|
||||||
new AuditRecordDto(
|
new AuditRecordDto(
|
||||||
AuditEvents.TenantLoginFailed,
|
AuditEvents.TenantLoginFailed,
|
||||||
AuditActorTypes.Tenant,
|
AuditActorTypes.Tenant,
|
||||||
@@ -258,12 +259,12 @@ public sealed class SuspiciousActivityServiceTests
|
|||||||
|
|
||||||
// Пишет запись «успешный вход» с заданным временем.
|
// Пишет запись «успешный вход» с заданным временем.
|
||||||
private static void SeedSuccess(
|
private static void SeedSuccess(
|
||||||
FakeAuditLogStore store,
|
TestAuditLogStore store,
|
||||||
string ip,
|
string ip,
|
||||||
string login,
|
string login,
|
||||||
int minutesAgo)
|
int minutesAgo)
|
||||||
{
|
{
|
||||||
store.AppendAsync(
|
store.Store.AppendAsync(
|
||||||
new AuditRecordDto(
|
new AuditRecordDto(
|
||||||
AuditEvents.TenantLoginOk,
|
AuditEvents.TenantLoginOk,
|
||||||
AuditActorTypes.Tenant,
|
AuditActorTypes.Tenant,
|
||||||
@@ -275,5 +276,5 @@ public sealed class SuspiciousActivityServiceTests
|
|||||||
CancellationToken.None);
|
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 tenantStore = new TestTenantStore();
|
||||||
var provisioner = new TestTenantProvisioner();
|
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);
|
TenantCreateResultDto result = await service.CreateAsync(" Новый тенант ", email: null, CancellationToken.None);
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ public sealed class TenantAdminServiceTests
|
|||||||
public async Task CreateAsync_WithEmail_CreatesOwnerWithOneTimePassword()
|
public async Task CreateAsync_WithEmail_CreatesOwnerWithOneTimePassword()
|
||||||
{
|
{
|
||||||
var tenantStore = new TestTenantStore();
|
var tenantStore = new TestTenantStore();
|
||||||
var authStore = new FakeAuthStore();
|
var authStore = new TestAuthStore();
|
||||||
var hasher = TestHashers.New();
|
var hasher = TestHashers.New();
|
||||||
var service = NewService(tenantStore, authStore, new TestTenantProvisioner(), hasher);
|
var service = NewService(tenantStore, authStore, new TestTenantProvisioner(), hasher);
|
||||||
|
|
||||||
@@ -66,7 +66,7 @@ public sealed class TenantAdminServiceTests
|
|||||||
public async Task CreateAsync_WithTakenEmail_ReturnsEmailTakenWithoutTenant()
|
public async Task CreateAsync_WithTakenEmail_ReturnsEmailTakenWithoutTenant()
|
||||||
{
|
{
|
||||||
var tenantStore = new TestTenantStore();
|
var tenantStore = new TestTenantStore();
|
||||||
var authStore = new FakeAuthStore();
|
var authStore = new TestAuthStore();
|
||||||
authStore.AddUser(NewUser(Guid.NewGuid(), "owner@example.com"));
|
authStore.AddUser(NewUser(Guid.NewGuid(), "owner@example.com"));
|
||||||
var service = NewService(tenantStore, authStore);
|
var service = NewService(tenantStore, authStore);
|
||||||
|
|
||||||
@@ -82,7 +82,7 @@ public sealed class TenantAdminServiceTests
|
|||||||
public async Task CreateAsync_WithInvalidEmail_ReturnsInvalidEmail()
|
public async Task CreateAsync_WithInvalidEmail_ReturnsInvalidEmail()
|
||||||
{
|
{
|
||||||
var tenantStore = new TestTenantStore();
|
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);
|
TenantCreateResultDto result = await service.CreateAsync("Тенант", "not-an-email", CancellationToken.None);
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ public sealed class TenantAdminServiceTests
|
|||||||
public async Task CreateAsync_WithEmptyName_ReturnsNameRequired()
|
public async Task CreateAsync_WithEmptyName_ReturnsNameRequired()
|
||||||
{
|
{
|
||||||
var tenantStore = new TestTenantStore();
|
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);
|
TenantCreateResultDto result = await service.CreateAsync(" ", email: null, CancellationToken.None);
|
||||||
|
|
||||||
@@ -110,7 +110,7 @@ public sealed class TenantAdminServiceTests
|
|||||||
var tenantStore = new TestTenantStore(
|
var tenantStore = new TestTenantStore(
|
||||||
new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow.AddDays(-2)),
|
new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow.AddDays(-2)),
|
||||||
new TenantRecordDto(SecondTenantId, "Второй", TenantStatuses.Suspended, DateTimeOffset.UtcNow.AddDays(-1)));
|
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, "a@example.com"));
|
||||||
authStore.AddUser(NewUser(FirstTenantId, "b@example.com"));
|
authStore.AddUser(NewUser(FirstTenantId, "b@example.com"));
|
||||||
authStore.AddUser(NewUser(Guid.NewGuid(), "other@example.com"));
|
authStore.AddUser(NewUser(Guid.NewGuid(), "other@example.com"));
|
||||||
@@ -132,7 +132,7 @@ public sealed class TenantAdminServiceTests
|
|||||||
public async Task GetAsync_ForExistingTenant_ReturnsDetailWithUsers()
|
public async Task GetAsync_ForExistingTenant_ReturnsDetailWithUsers()
|
||||||
{
|
{
|
||||||
var tenantStore = new TestTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow));
|
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(FirstTenantId, "a@example.com"));
|
||||||
authStore.AddUser(NewUser(Guid.NewGuid(), "other@example.com"));
|
authStore.AddUser(NewUser(Guid.NewGuid(), "other@example.com"));
|
||||||
var service = NewService(tenantStore, authStore);
|
var service = NewService(tenantStore, authStore);
|
||||||
@@ -150,7 +150,7 @@ public sealed class TenantAdminServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetAsync_ForUnknownTenant_ReturnsNull()
|
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));
|
Assert.Null(await service.GetAsync(Guid.NewGuid(), CancellationToken.None));
|
||||||
}
|
}
|
||||||
@@ -159,7 +159,7 @@ public sealed class TenantAdminServiceTests
|
|||||||
public async Task ChangeStatusAsync_Suspend_UpdatesTenantAndReportsChanged()
|
public async Task ChangeStatusAsync_Suspend_UpdatesTenantAndReportsChanged()
|
||||||
{
|
{
|
||||||
var tenantStore = new TestTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Active, DateTimeOffset.UtcNow));
|
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);
|
TenantStatusChangeResultDto result = await service.ChangeStatusAsync(FirstTenantId, TenantStatuses.Suspended, CancellationToken.None);
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ public sealed class TenantAdminServiceTests
|
|||||||
public async Task ChangeStatusAsync_WhenAlreadyInStatus_ReturnsOkWithoutChange()
|
public async Task ChangeStatusAsync_WhenAlreadyInStatus_ReturnsOkWithoutChange()
|
||||||
{
|
{
|
||||||
var tenantStore = new TestTenantStore(new TenantRecordDto(FirstTenantId, "Первый", TenantStatuses.Suspended, DateTimeOffset.UtcNow));
|
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 без изменения (аудит не дублируется, идемпотентность).
|
// Повторный suspend приостановленного — Ok без изменения (аудит не дублируется, идемпотентность).
|
||||||
TenantStatusChangeResultDto result = await service.ChangeStatusAsync(FirstTenantId, TenantStatuses.Suspended, CancellationToken.None);
|
TenantStatusChangeResultDto result = await service.ChangeStatusAsync(FirstTenantId, TenantStatuses.Suspended, CancellationToken.None);
|
||||||
@@ -187,7 +187,7 @@ public sealed class TenantAdminServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ChangeStatusAsync_ForUnknownTenant_ReturnsNotFound()
|
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);
|
TenantStatusChangeResultDto result = await service.ChangeStatusAsync(Guid.NewGuid(), TenantStatuses.Suspended, CancellationToken.None);
|
||||||
|
|
||||||
@@ -196,15 +196,15 @@ public sealed class TenantAdminServiceTests
|
|||||||
Assert.Null(result.Tenant);
|
Assert.Null(result.Tenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Сервис на фейк-хранилищах (владелец-пользователи создаются через FakeAuthStore и хэшер-подставку).
|
// Сервис на подставках хранилищ (владелец-пользователи создаются через TestAuthStore и хэшер-подставку).
|
||||||
private static TenantAdminService NewService(
|
private static TenantAdminService NewService(
|
||||||
TestTenantStore tenantStore,
|
TestTenantStore tenantStore,
|
||||||
FakeAuthStore authStore,
|
TestAuthStore authStore,
|
||||||
TestTenantProvisioner? provisioner = null,
|
TestTenantProvisioner? provisioner = null,
|
||||||
IPasswordHasher? hasher = null)
|
IPasswordHasher? hasher = null)
|
||||||
{
|
{
|
||||||
var tenantService = new TenantService(tenantStore.Repository, provisioner?.Provisioner ?? new TestTenantProvisioner().Provisioner);
|
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.Abstractions;
|
||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
using Deal.Modules.Tenants.Application.Services;
|
using Deal.Modules.Tenants.Application.Services;
|
||||||
|
using NSubstitute;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Modules.Tenants;
|
namespace Deal.Tests.Unit.Modules.Tenants;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// In-memory реализация <see cref="ITenantLimitStore"/> для юнит-тестов recorder'а и бюджетного гейта/алертов
|
/// Подставка <see cref="ITenantLimitStore"/> для юнит-тестов recorder'а и бюджетного гейта/алертов:
|
||||||
|
/// сервисы получают NSubstitute-подставку (<see cref="Store"/>), тесты управляют сценарием через свойства
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class FakeTenantLimitStore : ITenantLimitStore
|
public sealed class TestTenantLimitStore
|
||||||
{
|
{
|
||||||
private sealed class Row
|
private sealed class Row
|
||||||
{
|
{
|
||||||
@@ -28,26 +30,46 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
|
|||||||
|
|
||||||
private readonly Dictionary<Guid, Row> _rows = new();
|
private readonly Dictionary<Guid, Row> _rows = new();
|
||||||
private readonly TokenBudgetService _budgetService = new();
|
private readonly TokenBudgetService _budgetService = new();
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Подставка порта лимитов (создаётся в конструкторе).
|
||||||
|
/// </summary>
|
||||||
|
public ITenantLimitStore Store { get; }
|
||||||
private readonly Func<DateTimeOffset> _utcNow;
|
private readonly Func<DateTimeOffset> _utcNow;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Создаёт фейк с системными часами и статусом тенанта active.
|
/// Создаёт подставку с системными часами и статусом тенанта active.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public FakeTenantLimitStore()
|
public TestTenantLimitStore()
|
||||||
: this(() => DateTimeOffset.UtcNow, TenantStatuses.Active)
|
: this(() => DateTimeOffset.UtcNow, TenantStatuses.Active)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Создаёт фейк с заданными часами и статусом тенанта
|
/// Создаёт подставку с заданными часами и статусом тенанта
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="utcNow">Источник текущего времени (UTC).</param>
|
/// <param name="utcNow">Источник текущего времени (UTC).</param>
|
||||||
/// <param name="tenantStatus">Статус тенанта для всех строк (константа <see cref="TenantStatuses"/>).</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);
|
ArgumentNullException.ThrowIfNull(utcNow);
|
||||||
_utcNow = utcNow;
|
_utcNow = utcNow;
|
||||||
TenantStatus = tenantStatus;
|
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>
|
/// <summary>
|
||||||
@@ -56,12 +78,12 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
|
|||||||
public string TenantStatus { get; set; }
|
public string TenantStatus { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Тенанты, для которых <see cref="GetStateAsync"/> бросает исключение (устойчивость сборщиков)
|
/// Тенанты, для которых <see cref="GetStateAsync"/> подставки бросает исключение (устойчивость сборщиков)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public HashSet<Guid> FailStateReads { get; } = new();
|
public HashSet<Guid> FailStateReads { get; } = new();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Кладёт готовую строку лимита
|
/// Кладёт готовую строку лимита (в состояние подставки)
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="tenantId">Тенант.</param>
|
/// <param name="tenantId">Тенант.</param>
|
||||||
/// <param name="budgetTokens">Бюджет периода.</param>
|
/// <param name="budgetTokens">Бюджет периода.</param>
|
||||||
@@ -116,18 +138,16 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
|
|||||||
/// <returns>True — строка существует.</returns>
|
/// <returns>True — строка существует.</returns>
|
||||||
public bool Exists(Guid tenantId) => _rows.ContainsKey(tenantId);
|
public bool Exists(Guid tenantId) => _rows.ContainsKey(tenantId);
|
||||||
|
|
||||||
/// <inheritdoc />
|
private Task<TenantLimitDto> GetOrCreateAsync(
|
||||||
public Task<TenantLimitDto> GetOrCreateAsync(
|
|
||||||
Guid tenantId,
|
Guid tenantId,
|
||||||
CancellationToken ct,
|
CancellationToken ct,
|
||||||
TokenLimitDefaults? defaults = null)
|
TokenLimitDefaults? defaults)
|
||||||
{
|
{
|
||||||
Row row = Ensure(tenantId, defaults ?? TokenBudgetDefaults.Default);
|
Row row = Ensure(tenantId, defaults ?? TokenBudgetDefaults.Default);
|
||||||
return Task.FromResult(ToLimitDto(row, tenantId));
|
return Task.FromResult(ToLimitDto(row, tenantId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private Task<BudgetStateDto> GetStateAsync(Guid tenantId, CancellationToken ct)
|
||||||
public Task<BudgetStateDto> GetStateAsync(Guid tenantId, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
if (FailStateReads.Contains(tenantId))
|
if (FailStateReads.Contains(tenantId))
|
||||||
{
|
{
|
||||||
@@ -139,8 +159,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
|
|||||||
return Task.FromResult(ToStateDto(row, tenantId));
|
return Task.FromResult(ToStateDto(row, tenantId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private Task<BudgetStateDto> AddUsageAsync(
|
||||||
public Task<BudgetStateDto> AddUsageAsync(
|
|
||||||
Guid tenantId,
|
Guid tenantId,
|
||||||
long tokens,
|
long tokens,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
@@ -155,8 +174,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
|
|||||||
return Task.FromResult(ToStateDto(row, tenantId));
|
return Task.FromResult(ToStateDto(row, tenantId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private Task<BudgetStateDto> UpdateBudgetAsync(
|
||||||
public Task<BudgetStateDto> UpdateBudgetAsync(
|
|
||||||
Guid tenantId,
|
Guid tenantId,
|
||||||
long budgetTokens,
|
long budgetTokens,
|
||||||
string period,
|
string period,
|
||||||
@@ -170,8 +188,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
|
|||||||
return Task.FromResult(ToStateDto(row, tenantId));
|
return Task.FromResult(ToStateDto(row, tenantId));
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private Task<bool> TryMarkWarnedAsync(Guid tenantId, CancellationToken ct)
|
||||||
public Task<bool> TryMarkWarnedAsync(Guid tenantId, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
Row row = Ensure(tenantId, TokenBudgetDefaults.Default);
|
Row row = Ensure(tenantId, TokenBudgetDefaults.Default);
|
||||||
ResetIfPeriodExpired(row);
|
ResetIfPeriodExpired(row);
|
||||||
@@ -184,8 +201,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
|
|||||||
return Task.FromResult(true);
|
return Task.FromResult(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private Task<bool> TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct)
|
||||||
public Task<bool> TryMarkNotifiedExhaustedAsync(Guid tenantId, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
Row row = Ensure(tenantId, TokenBudgetDefaults.Default);
|
Row row = Ensure(tenantId, TokenBudgetDefaults.Default);
|
||||||
ResetIfPeriodExpired(row);
|
ResetIfPeriodExpired(row);
|
||||||
@@ -198,8 +214,7 @@ public sealed class FakeTenantLimitStore : ITenantLimitStore
|
|||||||
return Task.FromResult(true);
|
return Task.FromResult(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private Task<int> ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct)
|
||||||
public Task<int> ResetExpiredPeriodsAsync(DateTimeOffset now, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
int reset = 0;
|
int reset = 0;
|
||||||
foreach (Row row in _rows.Values)
|
foreach (Row row in _rows.Values)
|
||||||
@@ -3,6 +3,7 @@ using Deal.Api.Dtos;
|
|||||||
using Deal.Api.Events;
|
using Deal.Api.Events;
|
||||||
using Deal.Api.Services;
|
using Deal.Api.Services;
|
||||||
using Deal.Contracts.Integrations.Models;
|
using Deal.Contracts.Integrations.Models;
|
||||||
|
using Deal.Tests.Unit.Support;
|
||||||
using Deal.Modules.Kanban.Application.Models;
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
using Deal.Modules.Kanban.Application.Services;
|
using Deal.Modules.Kanban.Application.Services;
|
||||||
using Deal.Modules.Pipeline.Application.Models;
|
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.Pipeline.Application.Services;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
using Deal.Tests.Unit.Contracts;
|
using Deal.Tests.Unit.Contracts;
|
||||||
using Deal.Tests.Unit.Modules.Cards;
|
|
||||||
using Deal.Tests.Unit.Modules.Kanban;
|
using Deal.Tests.Unit.Modules.Kanban;
|
||||||
|
using NSubstitute;
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
|
|
||||||
@@ -28,9 +29,9 @@ public sealed class AdminTickOrchestratorTests
|
|||||||
// Контекст теста: оркестратор на общих фейках + канал подписки.
|
// Контекст теста: оркестратор на общих фейках + канал подписки.
|
||||||
private sealed record Context(
|
private sealed record Context(
|
||||||
AdminTickOrchestrator Orchestrator,
|
AdminTickOrchestrator Orchestrator,
|
||||||
FakePipelineStore PipelineStore,
|
TestPipelineStore PipelineStore,
|
||||||
FakeKanjStore KanjStore,
|
TestKanjStore KanjStore,
|
||||||
FakeSettingsStore Settings,
|
TestSettingsStore Settings,
|
||||||
TestAiClassifier AiClassifier,
|
TestAiClassifier AiClassifier,
|
||||||
PipelinePumpGate PumpGate,
|
PipelinePumpGate PumpGate,
|
||||||
SseSubscription Subscription);
|
SseSubscription Subscription);
|
||||||
@@ -158,7 +159,7 @@ public sealed class AdminTickOrchestratorTests
|
|||||||
Assert.Equal("hold", payload.RootElement.GetProperty("containerId").GetString());
|
Assert.Equal("hold", payload.RootElement.GetProperty("containerId").GetString());
|
||||||
|
|
||||||
// «Выстрелившее» помечено fired: повторная выборка due пуста (признак держит строка БД).
|
// «Выстрелившее» помечено fired: повторная выборка due пуста (признак держит строка БД).
|
||||||
Assert.Empty(await ctx.KanjStore.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None));
|
Assert.Empty(await ctx.KanjStore.Store.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -194,33 +195,39 @@ public sealed class AdminTickOrchestratorTests
|
|||||||
|
|
||||||
// Собирает оркестратор на общих фейках: StorageTickService и воркер делят KanjStore/Settings,
|
// Собирает оркестратор на общих фейках: StorageTickService и воркер делят KanjStore/Settings,
|
||||||
// обработка и воркер — PipelineStore, напоминания — тот же KanjStore/Settings (CardsService);
|
// обработка и воркер — PipelineStore, напоминания — тот же KanjStore/Settings (CardsService);
|
||||||
// ML «не готов» (FakeMlClient.Predict не задан → сбой → «не уверен»).
|
// ML «не готов» (TestMlClient.Predict не задан → сбой → «не уверен»).
|
||||||
// withThrowingQueueRead: true — чтение очереди воркером бросает (сценарий сбоя pump).
|
// withThrowingQueueRead: true — чтение очереди воркером бросает (сценарий сбоя pump).
|
||||||
// withThrowingReminderCheck: true — выборка due-напоминаний бросает (сценарий сбоя проверки).
|
// withThrowingReminderCheck: true — выборка due-напоминаний бросает (сценарий сбоя проверки).
|
||||||
// Возвращает: Оркестратор, фейки и подписку канала тенанта.
|
// Возвращает: Оркестратор, фейки и подписку канала тенанта.
|
||||||
private static Context CreateContext(bool withThrowingQueueRead = false, bool withThrowingReminderCheck = false)
|
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();
|
TestPipelineStore store = new();
|
||||||
var kanjStore = new FakeKanjStore();
|
if (withThrowingQueueRead)
|
||||||
var mlClient = new FakeMlClient();
|
{
|
||||||
|
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 aiClassifier = new TestAiClassifier();
|
||||||
var rules = new IncomingRules(settings);
|
var rules = new IncomingRules(settings.Store);
|
||||||
var fieldsParser = new LocalFieldsParser(settings);
|
var fieldsParser = new LocalFieldsParser(settings.Store);
|
||||||
var ingest = new PipelineIngestService(store);
|
var ingest = new PipelineIngestService(store.Store);
|
||||||
var processing = new PipelineProcessingService(store, mlClient, ingest);
|
var processing = new PipelineProcessingService(store.Store, mlClient.Client, ingest);
|
||||||
var composer = new CardComposer(kanjStore, settings);
|
var composer = new CardComposer(kanjStore.Store, settings.Store);
|
||||||
var writer = new PipelineCardWriter(kanjStore, store, composer);
|
var writer = new PipelineCardWriter(kanjStore.Store, store.Store, composer);
|
||||||
var worker = new PipelineWorkerService(
|
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 broker = new SseBroker();
|
||||||
var tickService = new StorageTickService(kanjStore, settings);
|
var tickService = new StorageTickService(kanjStore.Store, settings.Store);
|
||||||
var toastPublisher = new StorageToastPublisher(broker);
|
var toastPublisher = new StorageToastPublisher(broker);
|
||||||
var pumpGate = new PipelinePumpGate();
|
var pumpGate = new PipelinePumpGate();
|
||||||
FakeKanjStore reminderStore = withThrowingReminderCheck ? new ThrowingDueKanjStore() : kanjStore;
|
TestKanjStore reminderStore = withThrowingReminderCheck ? new TestKanjStore(throwOnDueReminders: true) : kanjStore;
|
||||||
var cardsService = new CardsService(reminderStore, settings, mlClient, new TestFileStorage().Storage);
|
var cardsService = new CardsService(reminderStore.Store, settings.Store, mlClient.Client, new TestFileStorage().Storage);
|
||||||
var orchestrator = new AdminTickOrchestrator(
|
var orchestrator = new AdminTickOrchestrator(
|
||||||
tickService, processing, worker, cardsService, toastPublisher, broker, pumpGate,
|
tickService, processing, worker, cardsService, toastPublisher, broker, pumpGate,
|
||||||
NullLogger<AdminTickOrchestrator>.Instance);
|
NullLogger<AdminTickOrchestrator>.Instance);
|
||||||
@@ -229,16 +236,7 @@ public sealed class AdminTickOrchestratorTests
|
|||||||
broker.Subscribe(TenantA));
|
broker.Subscribe(TenantA));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Хранилище карточек со сбоем выборки due-напоминаний: ListDueRemindersAsync бросает (сценарий
|
// Сбой выборки due-напоминаний задаётся флагом throwOnDueReminders в TestKanjStore.
|
||||||
// «БД/схема недоступны» на проверке напоминаний — тик продолжается, reminders ответа пуст).
|
|
||||||
private sealed class ThrowingDueKanjStore : FakeKanjStore
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override Task<IReadOnlyList<CardReminderDueDto>> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Тестовый сбой выборки due-напоминаний (ListDueRemindersAsync).");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем.
|
// Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем.
|
||||||
private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
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; сценарий перекрывает текст/время).
|
// Строка очереди (status new; сценарий перекрывает текст/время).
|
||||||
private static QueueItemDto QueueRow(string id, string text) => SourceItemFactory.Queue(
|
private static QueueItemDto QueueRow(string id, string text) => SourceItemFactory.Queue(
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Deal.Api.Events;
|
|||||||
using Deal.Api.Hosting;
|
using Deal.Api.Hosting;
|
||||||
using Deal.Modules.Tenants.Application.Abstractions;
|
using Deal.Modules.Tenants.Application.Abstractions;
|
||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
|
using Deal.Tests.Unit.Support;
|
||||||
using Deal.Tests.Unit.Modules.Tenants;
|
using Deal.Tests.Unit.Modules.Tenants;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
@@ -27,7 +28,7 @@ public sealed class BudgetAlertSchedulerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunCycle_TwoCycles_PublishesToastOncePerThresholdPerTenant()
|
public async Task RunCycle_TwoCycles_PublishesToastOncePerThresholdPerTenant()
|
||||||
{
|
{
|
||||||
FakeTenantLimitStore limits = new();
|
TestTenantLimitStore limits = new();
|
||||||
limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 800);
|
limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 800);
|
||||||
limits.Preload(TenantB, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 1000);
|
limits.Preload(TenantB, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 1000);
|
||||||
await using ServiceProvider provider = BuildProvider(
|
await using ServiceProvider provider = BuildProvider(
|
||||||
@@ -54,7 +55,7 @@ public sealed class BudgetAlertSchedulerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunCycle_TenantBelowThreshold_NoToastAndNoFlagSet()
|
public async Task RunCycle_TenantBelowThreshold_NoToastAndNoFlagSet()
|
||||||
{
|
{
|
||||||
FakeTenantLimitStore limits = new();
|
TestTenantLimitStore limits = new();
|
||||||
limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 700);
|
limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 700);
|
||||||
await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits);
|
await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits);
|
||||||
|
|
||||||
@@ -66,7 +67,7 @@ public sealed class BudgetAlertSchedulerTests
|
|||||||
|
|
||||||
Assert.Empty(ReadToasts(subscriptionA));
|
Assert.Empty(ReadToasts(subscriptionA));
|
||||||
// Порог не достигнут — TryMark* не выставил флаг (повторный проход после роста расхода даст тост).
|
// Порог не достигнут — TryMark* не выставил флаг (повторный проход после роста расхода даст тост).
|
||||||
Assert.False(await limits.TryMarkWarnedAsync(TenantA, CancellationToken.None));
|
Assert.False(await limits.Store.TryMarkWarnedAsync(TenantA, CancellationToken.None));
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[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);
|
limits.Preload(TenantA, budgetTokens: 500, TenantLimitPeriods.Month, Now(), usedTokens: 700);
|
||||||
await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits);
|
await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits);
|
||||||
|
|
||||||
@@ -93,7 +94,7 @@ public sealed class BudgetAlertSchedulerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunCycle_NaturalSpendCrossing80Then100_PublishesOneToastPerThreshold()
|
public async Task RunCycle_NaturalSpendCrossing80Then100_PublishesOneToastPerThreshold()
|
||||||
{
|
{
|
||||||
FakeTenantLimitStore limits = new();
|
TestTenantLimitStore limits = new();
|
||||||
limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 0);
|
limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 0);
|
||||||
await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits);
|
await using ServiceProvider provider = BuildProvider(new TestTenantRepository(Tenant(TenantA)).Repository, limits);
|
||||||
|
|
||||||
@@ -101,7 +102,7 @@ public sealed class BudgetAlertSchedulerTests
|
|||||||
SseSubscription subscriptionA = broker.Subscribe(TenantA);
|
SseSubscription subscriptionA = broker.Subscribe(TenantA);
|
||||||
BudgetAlertScheduler scheduler = CreateScheduler(provider);
|
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);
|
await scheduler.RunCycleAsync(CancellationToken.None);
|
||||||
Assert.Equal(new[] { (Warned80ToastText, "bell") }, ReadToasts(subscriptionA));
|
Assert.Equal(new[] { (Warned80ToastText, "bell") }, ReadToasts(subscriptionA));
|
||||||
@@ -110,7 +111,7 @@ public sealed class BudgetAlertSchedulerTests
|
|||||||
Assert.Empty(ReadToasts(subscriptionA)); // повторный проход — флаг уже стоит, тост не дублируется
|
Assert.Empty(ReadToasts(subscriptionA)); // повторный проход — флаг уже стоит, тост не дублируется
|
||||||
|
|
||||||
// Расход до исчерпания (850 + 200 = 1050 ≥ 1000) → ещё ровно один тост (100%); 80% уже отмечен.
|
// Расход до исчерпания (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);
|
await scheduler.RunCycleAsync(CancellationToken.None);
|
||||||
Assert.Equal(new[] { (ExhaustedToastText, "bell") }, ReadToasts(subscriptionA));
|
Assert.Equal(new[] { (ExhaustedToastText, "bell") }, ReadToasts(subscriptionA));
|
||||||
|
|
||||||
@@ -124,11 +125,11 @@ public sealed class BudgetAlertSchedulerTests
|
|||||||
// tenants: Фейк реестра тенантов (обход прохода).
|
// tenants: Фейк реестра тенантов (обход прохода).
|
||||||
// limits: Фейк-хранилище лимитов (строки посеяны сценарием до прохода).
|
// limits: Фейк-хранилище лимитов (строки посеяны сценарием до прохода).
|
||||||
// Возвращает: Провайдер с сервисами цикла.
|
// Возвращает: Провайдер с сервисами цикла.
|
||||||
private static ServiceProvider BuildProvider(ITenantRepository tenants, FakeTenantLimitStore limits)
|
private static ServiceProvider BuildProvider(ITenantRepository tenants, TestTenantLimitStore limits)
|
||||||
{
|
{
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
services.AddSingleton(tenants);
|
services.AddSingleton(tenants);
|
||||||
services.AddScoped<ITenantLimitStore>(_ => limits);
|
services.AddScoped<ITenantLimitStore>(_ => limits.Store);
|
||||||
services.AddSingleton<SseBroker>();
|
services.AddSingleton<SseBroker>();
|
||||||
return services.BuildServiceProvider();
|
return services.BuildServiceProvider();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Deal.Contracts.Integrations.Models;
|
using Deal.Contracts.Integrations.Models;
|
||||||
|
using Deal.Tests.Unit.Support;
|
||||||
using Deal.Modules.Cards.Application.Sources;
|
using Deal.Modules.Cards.Application.Sources;
|
||||||
using Deal.Modules.Kanban.Application.Models;
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
using Deal.Modules.Kanban.Application.Services;
|
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.Pipeline.Application.Services;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
using Deal.Tests.Unit.Contracts;
|
using Deal.Tests.Unit.Contracts;
|
||||||
using Deal.Tests.Unit.Modules.Cards;
|
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Support;
|
namespace Deal.Tests.Unit.Support;
|
||||||
@@ -21,9 +21,9 @@ public sealed class CardReclassifierTests
|
|||||||
// Контекст теста: сервис поверх in-memory фейков.
|
// Контекст теста: сервис поверх in-memory фейков.
|
||||||
private sealed record Context(
|
private sealed record Context(
|
||||||
CardReclassifier Reclassifier,
|
CardReclassifier Reclassifier,
|
||||||
FakeKanjStore Store,
|
TestKanjStore Store,
|
||||||
FakeSettingsStore Settings,
|
TestSettingsStore Settings,
|
||||||
FakeMlClient MlClient,
|
TestMlClient MlClient,
|
||||||
TestAiClassifier AiClassifier,
|
TestAiClassifier AiClassifier,
|
||||||
ReclassifyGate Gate);
|
ReclassifyGate Gate);
|
||||||
|
|
||||||
@@ -31,16 +31,16 @@ public sealed class CardReclassifierTests
|
|||||||
// Возвращает: Сервис и фейки (сценарий до-настраивает карточки/доски/настройки).
|
// Возвращает: Сервис и фейки (сценарий до-настраивает карточки/доски/настройки).
|
||||||
private static Context CreateContext()
|
private static Context CreateContext()
|
||||||
{
|
{
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
var store = new FakeKanjStore();
|
var store = new TestKanjStore();
|
||||||
var mlClient = new FakeMlClient();
|
var mlClient = new TestMlClient();
|
||||||
var aiClassifier = new TestAiClassifier();
|
var aiClassifier = new TestAiClassifier();
|
||||||
var fieldsParser = new LocalFieldsParser(settings);
|
var fieldsParser = new LocalFieldsParser(settings.Store);
|
||||||
var composer = new CardComposer(store, settings);
|
var composer = new CardComposer(store.Store, settings.Store);
|
||||||
var cardsService = new CardsService(store, settings, mlClient, new TestFileStorage().Storage);
|
var cardsService = new CardsService(store.Store, settings.Store, mlClient.Client, new TestFileStorage().Storage);
|
||||||
var gate = new ReclassifyGate();
|
var gate = new ReclassifyGate();
|
||||||
var reclassifier = new CardReclassifier(
|
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);
|
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.Models;
|
||||||
using Deal.Modules.Kanban.Application.Services;
|
using Deal.Modules.Kanban.Application.Services;
|
||||||
using Deal.Tests.Unit.Contracts;
|
using Deal.Tests.Unit.Contracts;
|
||||||
using Deal.Tests.Unit.Modules.Cards;
|
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Support;
|
namespace Deal.Tests.Unit.Support;
|
||||||
@@ -16,7 +15,7 @@ public sealed class CardsServiceFilesTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Add_ImageMime_DetectsKindByMimeWritesObjectAndMeta()
|
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));
|
store.SeedCard(Card("c_1", updatedAtMs: 1));
|
||||||
byte[] content = Encoding.UTF8.GetBytes("данные-картинки");
|
byte[] content = Encoding.UTF8.GetBytes("данные-картинки");
|
||||||
using MemoryStream stream = new(content);
|
using MemoryStream stream = new(content);
|
||||||
@@ -42,7 +41,7 @@ public sealed class CardsServiceFilesTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Add_PdfExtensionWithoutMime_DetectsKindByExtension()
|
public async Task Add_PdfExtensionWithoutMime_DetectsKindByExtension()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _) = Create();
|
(CardsService service, TestKanjStore store, _) = Create();
|
||||||
store.SeedCard(Card("c_1"));
|
store.SeedCard(Card("c_1"));
|
||||||
|
|
||||||
CardFileDto? entry = await service.AddFileAsync(
|
CardFileDto? entry = await service.AddFileAsync(
|
||||||
@@ -57,7 +56,7 @@ public sealed class CardsServiceFilesTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Add_TwoFiles_AppendsPreservingOrderAndBothObjects()
|
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"));
|
store.SeedCard(Card("c_1"));
|
||||||
|
|
||||||
CardFileDto? first = await service.AddFileAsync(
|
CardFileDto? first = await service.AddFileAsync(
|
||||||
@@ -76,7 +75,7 @@ public sealed class CardsServiceFilesTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Add_CardMissing_ReturnsNullAndDoesNotWriteObject()
|
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(
|
CardFileDto? entry = await service.AddFileAsync(
|
||||||
"c_missing", "photo.png", "image/png", new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None);
|
"c_missing", "photo.png", "image/png", new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None);
|
||||||
@@ -89,7 +88,7 @@ public sealed class CardsServiceFilesTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Add_EmptyOrWhitespaceFileName_DefaultsToPrototypeFile()
|
public async Task Add_EmptyOrWhitespaceFileName_DefaultsToPrototypeFile()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _) = Create();
|
(CardsService service, TestKanjStore store, _) = Create();
|
||||||
store.SeedCard(Card("c_1"));
|
store.SeedCard(Card("c_1"));
|
||||||
|
|
||||||
CardFileDto? entry = await service.AddFileAsync(
|
CardFileDto? entry = await service.AddFileAsync(
|
||||||
@@ -105,7 +104,7 @@ public sealed class CardsServiceFilesTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Add_FileNameWithPathAndQuoteChars_SanitizesObjectKeyButKeepsMetaName()
|
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"));
|
store.SeedCard(Card("c_1"));
|
||||||
string rawName = "..\\файл\"отчёта v2.pdf";
|
string rawName = "..\\файл\"отчёта v2.pdf";
|
||||||
|
|
||||||
@@ -123,7 +122,7 @@ public sealed class CardsServiceFilesTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Add_StreamPositionNotZero_StoresWholeContent()
|
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"));
|
store.SeedCard(Card("c_1"));
|
||||||
byte[] content = Encoding.UTF8.GetBytes("полное-содержимое-файла");
|
byte[] content = Encoding.UTF8.GetBytes("полное-содержимое-файла");
|
||||||
using MemoryStream stream = new(content) { Position = 5 }; // эндпоинт мог прочитать поток раньше
|
using MemoryStream stream = new(content) { Position = 5 }; // эндпоинт мог прочитать поток раньше
|
||||||
@@ -141,7 +140,7 @@ public sealed class CardsServiceFilesTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetEntry_ExistingFile_ReturnsEntryMeta()
|
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");
|
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 } });
|
store.SeedCard(Card("c_1") with { Files = new[] { file } });
|
||||||
|
|
||||||
@@ -164,7 +163,7 @@ public sealed class CardsServiceFilesTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetEntry_FileNotInMetadata_ReturnsNull()
|
public async Task GetEntry_FileNotInMetadata_ReturnsNull()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _) = Create();
|
(CardsService service, TestKanjStore store, _) = Create();
|
||||||
store.SeedCard(Card("c_1")
|
store.SeedCard(Card("c_1")
|
||||||
with { Files = new[] { new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "k") } });
|
with { Files = new[] { new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "k") } });
|
||||||
|
|
||||||
@@ -177,7 +176,7 @@ public sealed class CardsServiceFilesTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Remove_Existing_DeletesObjectRemovesMetaAndReturnsCard()
|
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 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");
|
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 } });
|
store.SeedCard(Card("c_1", updatedAtMs: 1) with { Files = new[] { first, second } });
|
||||||
@@ -199,7 +198,7 @@ public sealed class CardsServiceFilesTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Remove_UnknownFileId_ReturnsCardUnchangedWithoutStorageDelete()
|
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");
|
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 } });
|
store.SeedCard(Card("c_1") with { Files = new[] { file } });
|
||||||
|
|
||||||
@@ -214,7 +213,7 @@ public sealed class CardsServiceFilesTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Remove_EntryWithEmptyObjectKey_SkipsStorageDelete()
|
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")
|
store.SeedCard(Card("c_1")
|
||||||
with { Files = new[] { new CardFileDto("pf_mock", "meta-only.pdf", 10, "document", "Документ", ObjectKey: string.Empty) } });
|
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();
|
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 пуст).
|
// Карточка с полями по умолчанию (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.Models;
|
||||||
using Deal.Modules.Kanban.Application.Services;
|
using Deal.Modules.Kanban.Application.Services;
|
||||||
using Deal.Tests.Unit.Contracts;
|
using Deal.Tests.Unit.Contracts;
|
||||||
using Deal.Tests.Unit.Modules.Cards;
|
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Support;
|
namespace Deal.Tests.Unit.Support;
|
||||||
@@ -16,7 +15,7 @@ public sealed class CardsServiceSelectedTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task List_NoStage_ReturnsCardsOrderedByUpdatedAtDesc()
|
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_1", updatedAtMs: 100));
|
||||||
store.SeedCard(Card("c_2", updatedAtMs: 300));
|
store.SeedCard(Card("c_2", updatedAtMs: 300));
|
||||||
store.SeedCard(Card("c_3", stage: "work", updatedAtMs: 200));
|
store.SeedCard(Card("c_3", stage: "work", updatedAtMs: 200));
|
||||||
@@ -29,7 +28,7 @@ public sealed class CardsServiceSelectedTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task List_ByStage_ReturnsOnlyStageCards()
|
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_1"));
|
||||||
store.SeedCard(Card("c_2", stage: "work"));
|
store.SeedCard(Card("c_2", stage: "work"));
|
||||||
|
|
||||||
@@ -53,7 +52,7 @@ public sealed class CardsServiceSelectedTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreateLocal_WithStage_TrimsTitleAndWritesCreatedLocalHistory()
|
public async Task CreateLocal_WithStage_TrimsTitleAndWritesCreatedLocalHistory()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||||
|
|
||||||
CardDto card = await service.CreateLocalCardAsync(
|
CardDto card = await service.CreateLocalCardAsync(
|
||||||
new CardLocalCreateDto(Title: " Задача на бота ", ContainerId: "work"),
|
new CardLocalCreateDto(Title: " Задача на бота ", ContainerId: "work"),
|
||||||
@@ -102,7 +101,7 @@ public sealed class CardsServiceSelectedTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task TakeCard_CardMissing_ReturnsNullAndCreatesNothing()
|
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);
|
CardDto? card = await service.TakeCardAsync("c_missing", CancellationToken.None);
|
||||||
|
|
||||||
@@ -113,7 +112,7 @@ public sealed class CardsServiceSelectedTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task TakeCard_MovesCardToPlannedKeepingFields()
|
public async Task TakeCard_MovesCardToPlannedKeepingFields()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||||
store.SeedCard(Card(
|
store.SeedCard(Card(
|
||||||
"c_1",
|
"c_1",
|
||||||
stage: "inbox",
|
stage: "inbox",
|
||||||
@@ -150,7 +149,7 @@ public sealed class CardsServiceSelectedTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task TakeCard_AlreadyInStage_ReturnsCardUnchanged()
|
public async Task TakeCard_AlreadyInStage_ReturnsCardUnchanged()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||||
store.SeedCard(Card("c_1", stage: "work", title: "Уже взята"));
|
store.SeedCard(Card("c_1", stage: "work", title: "Уже взята"));
|
||||||
|
|
||||||
CardDto? card = await service.TakeCardAsync("c_1", CancellationToken.None);
|
CardDto? card = await service.TakeCardAsync("c_1", CancellationToken.None);
|
||||||
@@ -167,7 +166,7 @@ public sealed class CardsServiceSelectedTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Patch_PresentKeys_UpdateFieldsAndBumpUpdatedAt()
|
public async Task Patch_PresentKeys_UpdateFieldsAndBumpUpdatedAt()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||||
store.SeedCard(Card(
|
store.SeedCard(Card(
|
||||||
"c_1",
|
"c_1",
|
||||||
title: "Старый заголовок",
|
title: "Старый заголовок",
|
||||||
@@ -198,7 +197,7 @@ public sealed class CardsServiceSelectedTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Patch_BudgetNull_ClearsBudget()
|
public async Task Patch_BudgetNull_ClearsBudget()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||||
store.SeedCard(Card("c_1", title: "С бюджетом", updatedAtMs: 1)
|
store.SeedCard(Card("c_1", title: "С бюджетом", updatedAtMs: 1)
|
||||||
with { Budget = new CardBudgetDto(1000, 2000, "USD") });
|
with { Budget = new CardBudgetDto(1000, 2000, "USD") });
|
||||||
|
|
||||||
@@ -213,7 +212,7 @@ public sealed class CardsServiceSelectedTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Patch_StackNull_ClearsStack()
|
public async Task Patch_StackNull_ClearsStack()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||||
store.SeedCard(Card("c_1", title: "С стеком", updatedAtMs: 1)
|
store.SeedCard(Card("c_1", title: "С стеком", updatedAtMs: 1)
|
||||||
with { Stack = new[] { "Python" } });
|
with { Stack = new[] { "Python" } });
|
||||||
|
|
||||||
@@ -226,7 +225,7 @@ public sealed class CardsServiceSelectedTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Patch_UnknownKeys_AreIgnored()
|
public async Task Patch_UnknownKeys_AreIgnored()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||||
store.SeedCard(Card("c_1", title: "Старый"));
|
store.SeedCard(Card("c_1", title: "Старый"));
|
||||||
|
|
||||||
CardDto? card = await service.PatchCardAsync(
|
CardDto? card = await service.PatchCardAsync(
|
||||||
@@ -242,7 +241,7 @@ public sealed class CardsServiceSelectedTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Patch_NullTextKey_IsIgnored()
|
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));
|
store.SeedCard(Card("c_1", title: "Старый", summary: "Описание", updatedAtMs: 1));
|
||||||
|
|
||||||
CardDto? card = await service.PatchCardAsync(
|
CardDto? card = await service.PatchCardAsync(
|
||||||
@@ -272,7 +271,7 @@ public sealed class CardsServiceSelectedTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Move_TwoMoves_AppendHistoryResetReminderAndBumpUpdatedAt()
|
public async Task Move_TwoMoves_AppendHistoryResetReminderAndBumpUpdatedAt()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||||
long seededAt = 5_000;
|
long seededAt = 5_000;
|
||||||
CardDto before = Card("c_1", stage: "hold", title: "Отложенная", updatedAtMs: seededAt)
|
CardDto before = Card("c_1", stage: "hold", title: "Отложенная", updatedAtMs: seededAt)
|
||||||
with
|
with
|
||||||
@@ -310,7 +309,7 @@ public sealed class CardsServiceSelectedTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Move_UnknownStage_Returns400ErrorAndLeavesCardUntouched()
|
public async Task Move_UnknownStage_Returns400ErrorAndLeavesCardUntouched()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||||
store.SeedCard(Card("c_1", stage: "planned"));
|
store.SeedCard(Card("c_1", stage: "planned"));
|
||||||
|
|
||||||
CardResultDto result = await service.MoveStageCardAsync("c_1", "stuck", CancellationToken.None);
|
CardResultDto result = await service.MoveStageCardAsync("c_1", "stuck", CancellationToken.None);
|
||||||
@@ -337,7 +336,7 @@ public sealed class CardsServiceSelectedTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ClearRejected_RemovesOnlyRejectedAndReturnsCount()
|
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_1", stage: "rejected"));
|
||||||
store.SeedCard(Card("c_2", stage: "rejected"));
|
store.SeedCard(Card("c_2", stage: "rejected"));
|
||||||
store.SeedCard(Card("c_3", stage: "finished"));
|
store.SeedCard(Card("c_3", stage: "finished"));
|
||||||
@@ -363,7 +362,7 @@ public sealed class CardsServiceSelectedTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddComment_Valid_AppendsTrimmedCommentWithWireForm()
|
public async Task AddComment_Valid_AppendsTrimmedCommentWithWireForm()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||||
store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1));
|
store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1));
|
||||||
|
|
||||||
AddCommentResultDto result = await service.AddCommentAsync("c_1", " Перезвонить завтра ", CancellationToken.None);
|
AddCommentResultDto result = await service.AddCommentAsync("c_1", " Перезвонить завтра ", CancellationToken.None);
|
||||||
@@ -381,7 +380,7 @@ public sealed class CardsServiceSelectedTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddComment_TwoComments_AppendsPreservingOrder()
|
public async Task AddComment_TwoComments_AppendsPreservingOrder()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||||
store.SeedCard(Card("c_1", title: "Карточка")
|
store.SeedCard(Card("c_1", title: "Карточка")
|
||||||
with { Comments = new[] { new CardCommentDto("cm_1", "Вы", "Первый", "5 мин") } });
|
with { Comments = new[] { new CardCommentDto("cm_1", "Вы", "Первый", "5 мин") } });
|
||||||
|
|
||||||
@@ -396,7 +395,7 @@ public sealed class CardsServiceSelectedTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddComment_EmptyOrWhitespaceText_Returns400DetailAndWritesNothing()
|
public async Task AddComment_EmptyOrWhitespaceText_Returns400DetailAndWritesNothing()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||||
store.SeedCard(Card("c_1", title: "Карточка"));
|
store.SeedCard(Card("c_1", title: "Карточка"));
|
||||||
|
|
||||||
AddCommentResultDto empty = await service.AddCommentAsync("c_1", string.Empty, CancellationToken.None);
|
AddCommentResultDto empty = await service.AddCommentAsync("c_1", string.Empty, CancellationToken.None);
|
||||||
@@ -411,7 +410,7 @@ public sealed class CardsServiceSelectedTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddComment_CardMissing_ReturnsNullComments()
|
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);
|
AddCommentResultDto result = await service.AddCommentAsync("c_missing", "Текст", CancellationToken.None);
|
||||||
|
|
||||||
@@ -424,7 +423,7 @@ public sealed class CardsServiceSelectedTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddLink_NoScheme_PrefixesHttpsAndDefaultsNameToUrl()
|
public async Task AddLink_NoScheme_PrefixesHttpsAndDefaultsNameToUrl()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||||
store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1)
|
store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1)
|
||||||
with { Links = new[] { new CardLinkDto("pl_1", "Сайт", "https://example.com") } });
|
with { Links = new[] { new CardLinkDto("pl_1", "Сайт", "https://example.com") } });
|
||||||
|
|
||||||
@@ -444,7 +443,7 @@ public sealed class CardsServiceSelectedTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddLink_HasHttpScheme_KeepsSchemeAndUsesTrimmedName()
|
public async Task AddLink_HasHttpScheme_KeepsSchemeAndUsesTrimmedName()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||||
store.SeedCard(Card("c_1", title: "Карточка"));
|
store.SeedCard(Card("c_1", title: "Карточка"));
|
||||||
|
|
||||||
CardResultDto result = await service.AddLinkAsync("c_1", " Сайт ", "http://site.ru/abc", CancellationToken.None);
|
CardResultDto result = await service.AddLinkAsync("c_1", " Сайт ", "http://site.ru/abc", CancellationToken.None);
|
||||||
@@ -458,7 +457,7 @@ public sealed class CardsServiceSelectedTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddLink_EmptyUrl_Returns400DetailAndWritesNothing()
|
public async Task AddLink_EmptyUrl_Returns400DetailAndWritesNothing()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||||
store.SeedCard(Card("c_1", title: "Карточка"));
|
store.SeedCard(Card("c_1", title: "Карточка"));
|
||||||
|
|
||||||
CardResultDto result = await service.AddLinkAsync("c_1", "Имя", " ", CancellationToken.None);
|
CardResultDto result = await service.AddLinkAsync("c_1", "Имя", " ", CancellationToken.None);
|
||||||
@@ -471,7 +470,7 @@ public sealed class CardsServiceSelectedTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddLink_CardMissing_ReturnsNullCardBeforeUrlValidation()
|
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);
|
CardResultDto result = await service.AddLinkAsync("c_missing", string.Empty, "example.com", CancellationToken.None);
|
||||||
|
|
||||||
@@ -483,7 +482,7 @@ public sealed class CardsServiceSelectedTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task RemoveLink_ById_RemovesOnlyTargetAndReturnsCard()
|
public async Task RemoveLink_ById_RemovesOnlyTargetAndReturnsCard()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||||
store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1)
|
store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1)
|
||||||
with
|
with
|
||||||
{
|
{
|
||||||
@@ -507,7 +506,7 @@ public sealed class CardsServiceSelectedTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task RemoveLink_UnknownLinkId_LeavesLinksUnchangedWithoutError()
|
public async Task RemoveLink_UnknownLinkId_LeavesLinksUnchangedWithoutError()
|
||||||
{
|
{
|
||||||
(CardsService service, FakeKanjStore store, _, _) = Create();
|
(CardsService service, TestKanjStore store, _, _) = Create();
|
||||||
store.SeedCard(Card("c_1", title: "Карточка")
|
store.SeedCard(Card("c_1", title: "Карточка")
|
||||||
with { Links = new[] { new CardLinkDto("pl_1", "Сайт", "https://a.b") } });
|
with { Links = new[] { new CardLinkDto("pl_1", "Сайт", "https://a.b") } });
|
||||||
|
|
||||||
@@ -520,7 +519,7 @@ public sealed class CardsServiceSelectedTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task RemoveLink_CardMissing_ReturnsNullCard()
|
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);
|
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 store = new TestKanjStore();
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
var ml = new FakeMlClient();
|
var ml = new TestMlClient();
|
||||||
return (new CardsService(store, settings, ml, new TestFileStorage().Storage), store, settings, ml);
|
return (new CardsService(store.Store, settings.Store, ml.Client, new TestFileStorage().Storage), store, settings, ml);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Тело PATCH из пар «ключ → значение» (presence = наличие пары; null — явный JSON-null).
|
// Тело PATCH из пар «ключ → значение» (presence = наличие пары; null — явный JSON-null).
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Deal.Modules.Kanban.Application.Models;
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
using Deal.Modules.Kanban.Application.Services;
|
using Deal.Modules.Kanban.Application.Services;
|
||||||
using Deal.Tests.Unit.Modules.Cards;
|
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Support;
|
namespace Deal.Tests.Unit.Support;
|
||||||
@@ -29,7 +28,7 @@ public sealed class ContainersServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Create_ExistingContainers_OrderIsMaxPlusOne()
|
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_2", 2));
|
||||||
store.SeedBoard(Container("b_5", 5));
|
store.SeedBoard(Container("b_5", 5));
|
||||||
|
|
||||||
@@ -43,7 +42,7 @@ public sealed class ContainersServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Create_OrderEight_CyclesPaletteToFirst()
|
public async Task Create_OrderEight_CyclesPaletteToFirst()
|
||||||
{
|
{
|
||||||
(ContainersService service, FakeKanjStore store, _) = Create();
|
(ContainersService service, TestKanjStore store, _) = Create();
|
||||||
store.SeedBoard(Container("b_7", 7));
|
store.SeedBoard(Container("b_7", 7));
|
||||||
|
|
||||||
ContainerDto container = await service.CreateAsync(new ContainerCreateDto("Middle"), CancellationToken.None);
|
ContainerDto container = await service.CreateAsync(new ContainerCreateDto("Middle"), CancellationToken.None);
|
||||||
@@ -157,7 +156,7 @@ public sealed class ContainersServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task List_FillsCountsFromCards()
|
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);
|
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_1", Col = container.Id, IsNew = true });
|
||||||
store.SeedCard(new CardDto { Id = "c_2", 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]
|
[Fact]
|
||||||
public async Task Reorder_AssignsOrdersInGivenOrder()
|
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_1", 0));
|
||||||
store.SeedBoard(Container("b_2", 1));
|
store.SeedBoard(Container("b_2", 1));
|
||||||
store.SeedBoard(Container("b_3", 2));
|
store.SeedBoard(Container("b_3", 2));
|
||||||
|
|
||||||
await service.ReorderAsync(ContainerSpaces.Dashboard, ["b_3", "b_1", "b_2"], CancellationToken.None);
|
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(0, (await store.Store.GetContainerAsync("b_3", CancellationToken.None))!.Order);
|
||||||
Assert.Equal(1, (await store.GetContainerAsync("b_1", CancellationToken.None))!.Order);
|
Assert.Equal(1, (await store.Store.GetContainerAsync("b_1", CancellationToken.None))!.Order);
|
||||||
Assert.Equal(2, (await store.GetContainerAsync("b_2", CancellationToken.None))!.Order);
|
Assert.Equal(2, (await store.Store.GetContainerAsync("b_2", CancellationToken.None))!.Order);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Удаление контейнера ──────────────────────────────────────────────
|
// ─── Удаление контейнера ──────────────────────────────────────────────
|
||||||
@@ -300,7 +299,7 @@ public sealed class ContainersServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Delete_MovesCardsToInbox_ReturnsMovedCount()
|
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);
|
ContainerDto container = await service.CreateAsync(new ContainerCreateDto("Middle"), CancellationToken.None);
|
||||||
store.AddCard("c_1", container.Id);
|
store.AddCard("c_1", container.Id);
|
||||||
store.AddCard("c_2", 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);
|
int moved = await service.DeleteAsync(container.Id, CancellationToken.None);
|
||||||
|
|
||||||
Assert.Equal(2, moved);
|
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_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_2" && card.Col == "inbox" && card.IsNew);
|
||||||
Assert.Contains(store.Cards, card => card.CardId == "c_inbox" && card.Col == "inbox"); // inbox не тронут
|
Assert.Contains(store.Cards, card => card.CardId == "c_inbox" && card.Col == "inbox"); // inbox не тронут
|
||||||
@@ -340,7 +339,7 @@ public sealed class ContainersServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetColState_ReturnsStoredColumnsAsIs()
|
public async Task GetColState_ReturnsStoredColumnsAsIs()
|
||||||
{
|
{
|
||||||
(ContainersService service, _, FakeSettingsStore settings) = Create();
|
(ContainersService service, _, TestSettingsStore settings) = Create();
|
||||||
settings.Preload("colState", """{"inbox":{"collapsed":true},"trash":{"width":"lg"}}""");
|
settings.Preload("colState", """{"inbox":{"collapsed":true},"trash":{"width":"lg"}}""");
|
||||||
|
|
||||||
IReadOnlyDictionary<string, ColumnStateDto> state = await service.GetColStateAsync(CancellationToken.None);
|
IReadOnlyDictionary<string, ColumnStateDto> state = await service.GetColStateAsync(CancellationToken.None);
|
||||||
@@ -353,7 +352,7 @@ public sealed class ContainersServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PatchColState_NewColumn_WritesStateAndReturnsIt()
|
public async Task PatchColState_NewColumn_WritesStateAndReturnsIt()
|
||||||
{
|
{
|
||||||
(ContainersService service, _, FakeSettingsStore settings) = Create();
|
(ContainersService service, _, TestSettingsStore settings) = Create();
|
||||||
|
|
||||||
ColumnStateDto result = await service.PatchColStateAsync(
|
ColumnStateDto result = await service.PatchColStateAsync(
|
||||||
"inbox", new ColumnStateDto(Collapsed: true, Width: null), CancellationToken.None);
|
"inbox", new ColumnStateDto(Collapsed: true, Width: null), CancellationToken.None);
|
||||||
@@ -368,7 +367,7 @@ public sealed class ContainersServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PatchColState_ExistingColumn_MergesPatchIntoCurrent()
|
public async Task PatchColState_ExistingColumn_MergesPatchIntoCurrent()
|
||||||
{
|
{
|
||||||
(ContainersService service, _, FakeSettingsStore settings) = Create();
|
(ContainersService service, _, TestSettingsStore settings) = Create();
|
||||||
settings.Preload("colState", """{"inbox":{"collapsed":true}}""");
|
settings.Preload("colState", """{"inbox":{"collapsed":true}}""");
|
||||||
|
|
||||||
ColumnStateDto result = await service.PatchColStateAsync(
|
ColumnStateDto result = await service.PatchColStateAsync(
|
||||||
@@ -380,7 +379,7 @@ public sealed class ContainersServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PatchColState_NullFields_KeepCurrentState()
|
public async Task PatchColState_NullFields_KeepCurrentState()
|
||||||
{
|
{
|
||||||
(ContainersService service, _, FakeSettingsStore settings) = Create();
|
(ContainersService service, _, TestSettingsStore settings) = Create();
|
||||||
settings.Preload("colState", """{"archive":{"collapsed":true,"width":"sm"}}""");
|
settings.Preload("colState", """{"archive":{"collapsed":true,"width":"sm"}}""");
|
||||||
|
|
||||||
ColumnStateDto result = await service.PatchColStateAsync(
|
ColumnStateDto result = await service.PatchColStateAsync(
|
||||||
@@ -392,7 +391,7 @@ public sealed class ContainersServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PatchColState_EmptyPatch_KeepsOtherColumnsAndWritesEmptyEntry()
|
public async Task PatchColState_EmptyPatch_KeepsOtherColumnsAndWritesEmptyEntry()
|
||||||
{
|
{
|
||||||
(ContainersService service, _, FakeSettingsStore settings) = Create();
|
(ContainersService service, _, TestSettingsStore settings) = Create();
|
||||||
settings.Preload("colState", """{"archive":{"collapsed":true}}""");
|
settings.Preload("colState", """{"archive":{"collapsed":true}}""");
|
||||||
|
|
||||||
await service.PatchColStateAsync("inbox", new ColumnStateDto(Collapsed: null, Width: null), CancellationToken.None);
|
await service.PatchColStateAsync("inbox", new ColumnStateDto(Collapsed: null, Width: null), CancellationToken.None);
|
||||||
@@ -408,7 +407,7 @@ public sealed class ContainersServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetColState_CorruptJson_ReturnsEmpty()
|
public async Task GetColState_CorruptJson_ReturnsEmpty()
|
||||||
{
|
{
|
||||||
(ContainersService service, _, FakeSettingsStore settings) = Create();
|
(ContainersService service, _, TestSettingsStore settings) = Create();
|
||||||
settings.Preload("colState", "{oops");
|
settings.Preload("colState", "{oops");
|
||||||
|
|
||||||
IReadOnlyDictionary<string, ColumnStateDto> state = await service.GetColStateAsync(CancellationToken.None);
|
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 store = new TestKanjStore();
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
return (new ContainersService(store, settings), store, settings);
|
return (new ContainersService(store.Store, settings.Store), store, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Патч только с нужными полями: остальные параметры null = «не менять».
|
// Патч только с нужными полями: остальные параметры null = «не менять».
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ using Deal.Modules.Kanban.Application.Models;
|
|||||||
using Deal.Modules.Kanban.Application.Services;
|
using Deal.Modules.Kanban.Application.Services;
|
||||||
using Deal.Modules.Settings.Application.Abstractions;
|
using Deal.Modules.Settings.Application.Abstractions;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
|
using Deal.Tests.Unit.Support;
|
||||||
using Deal.Modules.Settings.Application.Services;
|
using Deal.Modules.Settings.Application.Services;
|
||||||
using Deal.Tests.Unit.Modules.Cards;
|
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Support;
|
namespace Deal.Tests.Unit.Support;
|
||||||
@@ -19,7 +19,7 @@ public sealed class ConversionRecomputerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Recompute_MockRates_ConvertsUsdBudgetToRub()
|
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));
|
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
|
||||||
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
|
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"));
|
store.SeedCard(Card("l_inbox", KanbanColumns.Inbox, from: 50, to: 200, cur: "USD"));
|
||||||
@@ -34,7 +34,7 @@ public sealed class ConversionRecomputerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Recompute_TargetCurrencyFromSettings_ConvertsToConfiguredCurrency()
|
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.RatesCache, CacheJson(MockRates.Values));
|
||||||
settings.Preload(SettingsKeys.TargetCurrency, "\"EUR\""); // PATCH пишет Upper (ApplyStringKey)
|
settings.Preload(SettingsKeys.TargetCurrency, "\"EUR\""); // PATCH пишет Upper (ApplyStringKey)
|
||||||
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
|
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
|
||||||
@@ -49,7 +49,7 @@ public sealed class ConversionRecomputerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Recompute_NoTargetCurrencySetting_DefaultsToRub()
|
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));
|
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
|
||||||
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
|
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
|
||||||
|
|
||||||
@@ -62,7 +62,7 @@ public sealed class ConversionRecomputerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Recompute_UsdtBudget_TreatedAsUsdRate()
|
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 };
|
var rates = new Dictionary<string, double> { ["RUB"] = 1.0, ["USD"] = 100.0, ["USDT"] = 90.0 };
|
||||||
settings.Preload(SettingsKeys.RatesCache, CacheJson(rates));
|
settings.Preload(SettingsKeys.RatesCache, CacheJson(rates));
|
||||||
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USDT"));
|
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USDT"));
|
||||||
@@ -78,7 +78,7 @@ public sealed class ConversionRecomputerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Recompute_BudgetWithoutUpperBound_UsesLowerBoundForConvTo()
|
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));
|
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
|
||||||
store.SeedCard(Card("l_board", "b_1", from: 100, to: null, cur: "USD"));
|
store.SeedCard(Card("l_board", "b_1", from: 100, to: null, cur: "USD"));
|
||||||
|
|
||||||
@@ -93,7 +93,7 @@ public sealed class ConversionRecomputerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Recompute_ConversionOff_ReturnsZeroAndKeepsCardsUntouched()
|
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.ConversionOn, "false");
|
||||||
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
|
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
|
||||||
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
|
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
|
||||||
@@ -109,7 +109,7 @@ public sealed class ConversionRecomputerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Recompute_ExcludesArchiveAndTrashCards()
|
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));
|
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
|
||||||
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
|
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"));
|
store.SeedCard(Card("l_archive", KanbanColumns.Archive, from: 100, to: 100, cur: "USD"));
|
||||||
@@ -128,7 +128,7 @@ public sealed class ConversionRecomputerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Recompute_MissingCurrencyInRates_SkipsCardKeepingOldConversion()
|
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 }));
|
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
|
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "XXX") with
|
||||||
{
|
{
|
||||||
@@ -147,7 +147,7 @@ public sealed class ConversionRecomputerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Recompute_NoRatesCache_ReturnsZeroAndKeepsCardsUntouched()
|
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"));
|
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
|
||||||
|
|
||||||
int updated = await service.RecomputeAsync(CancellationToken.None);
|
int updated = await service.RecomputeAsync(CancellationToken.None);
|
||||||
@@ -159,7 +159,7 @@ public sealed class ConversionRecomputerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Recompute_CorruptedRatesCache_ReturnsZero()
|
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");
|
settings.Preload(SettingsKeys.RatesCache, "{это не JSON");
|
||||||
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
|
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
|
||||||
|
|
||||||
@@ -174,7 +174,7 @@ public sealed class ConversionRecomputerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task OnRatesChangedAsync_PerformsFullRecompute()
|
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));
|
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
|
||||||
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
|
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
|
||||||
|
|
||||||
@@ -189,7 +189,7 @@ public sealed class ConversionRecomputerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Recompute_SecondRun_RecomputesSameValues()
|
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));
|
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
|
||||||
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
|
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 store = new TestKanjStore();
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
return (new ConversionRecomputer(settings, store), store, settings);
|
return (new ConversionRecomputer(settings.Store, store.Store), store, settings);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Карточка с бюджетом в колонке (BudgetCur задан → Budget не null, как маппинг адаптера).
|
// Карточка с бюджетом в колонке (BudgetCur задан → Budget не null, как маппинг адаптера).
|
||||||
@@ -237,7 +237,7 @@ public sealed class ConversionRecomputerTests
|
|||||||
// Конверсия (Converted) карточки в фейке после пересчёта.
|
// Конверсия (Converted) карточки в фейке после пересчёта.
|
||||||
// store: Фейк-хранилище.
|
// store: Фейк-хранилище.
|
||||||
// cardId: Id карточки.
|
// 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;
|
store.CardDtos.Single(card => card.Id == cardId).Converted;
|
||||||
|
|
||||||
// Проверяет conv-поля карточки (From/To с допуском 0.001 и валюту).
|
// Проверяет conv-поля карточки (From/To с допуском 0.001 и валюту).
|
||||||
@@ -247,7 +247,7 @@ public sealed class ConversionRecomputerTests
|
|||||||
// convTo: Ожидаемая верхняя граница.
|
// convTo: Ожидаемая верхняя граница.
|
||||||
// convCur: Ожидаемая валюта конверсии.
|
// convCur: Ожидаемая валюта конверсии.
|
||||||
private static void AssertConverted(
|
private static void AssertConverted(
|
||||||
FakeKanjStore store,
|
TestKanjStore store,
|
||||||
string cardId,
|
string cardId,
|
||||||
double convFrom,
|
double convFrom,
|
||||||
double convTo,
|
double convTo,
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ using Deal.Modules.Settings.Application.Models;
|
|||||||
using Deal.Modules.Tenants.Application.Services;
|
using Deal.Modules.Tenants.Application.Services;
|
||||||
using Deal.SharedKernel.Tenants.Models;
|
using Deal.SharedKernel.Tenants.Models;
|
||||||
using Deal.Tests.Unit.Grpc;
|
using Deal.Tests.Unit.Grpc;
|
||||||
using Deal.Tests.Unit.Modules.Cards;
|
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
using Deal.Tests.Unit.Modules.Tenants;
|
using Deal.Tests.Unit.Modules.Tenants;
|
||||||
using Microsoft.Extensions.Logging.Abstractions;
|
using Microsoft.Extensions.Logging.Abstractions;
|
||||||
@@ -52,12 +51,12 @@ public sealed class GrpcAiClassifierTests
|
|||||||
Reason = "реклама",
|
Reason = "реклама",
|
||||||
Usage = new Usage { Prompt = 500, Completion = 40, Total = 540 },
|
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.AiFilterPrompt, Json("Фильтруй. {domain} | {keywords}."));
|
||||||
settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain));
|
settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain));
|
||||||
settings.Preload(SettingsKeys.DomainKeywords, Json(TestKeywords));
|
settings.Preload(SettingsKeys.DomainKeywords, Json(TestKeywords));
|
||||||
|
|
||||||
FakeTenantLimitStore limits = new();
|
TestTenantLimitStore limits = new();
|
||||||
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits);
|
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits);
|
||||||
AiFilterResultDto result = await classifier.FilterAsync("Купите телеграм-канал", CancellationToken.None);
|
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) =>
|
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
|
||||||
{
|
{
|
||||||
service.FilterUnavailable = true;
|
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);
|
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
|
||||||
|
|
||||||
await Assert.ThrowsAsync<AiUnavailableException>(
|
await Assert.ThrowsAsync<AiUnavailableException>(
|
||||||
@@ -102,7 +101,7 @@ public sealed class GrpcAiClassifierTests
|
|||||||
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
|
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
|
||||||
{
|
{
|
||||||
string text = new string('а', 5000);
|
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);
|
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
|
||||||
|
|
||||||
await classifier.FilterAsync(text, CancellationToken.None);
|
await classifier.FilterAsync(text, CancellationToken.None);
|
||||||
@@ -131,7 +130,7 @@ public sealed class GrpcAiClassifierTests
|
|||||||
""",
|
""",
|
||||||
Usage = new Usage { Prompt = 3000, Completion = 700, Total = 3700 },
|
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.AiPrompt, Json("Разбор: {domain}."));
|
||||||
settings.Preload(SettingsKeys.CardPrompt, Json("Верни блок «О заявке»."));
|
settings.Preload(SettingsKeys.CardPrompt, Json("Верни блок «О заявке»."));
|
||||||
settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain));
|
settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain));
|
||||||
@@ -152,7 +151,7 @@ public sealed class GrpcAiClassifierTests
|
|||||||
Order = 0,
|
Order = 0,
|
||||||
});
|
});
|
||||||
|
|
||||||
FakeTenantLimitStore limits = new();
|
TestTenantLimitStore limits = new();
|
||||||
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits);
|
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits);
|
||||||
AiParsedCardDto parsed = await classifier.ClassifyAsync("Нужен Python-разработчик, оплата от 2000$", CancellationToken.None);
|
AiParsedCardDto parsed = await classifier.ClassifyAsync("Нужен Python-разработчик, оплата от 2000$", CancellationToken.None);
|
||||||
|
|
||||||
@@ -191,9 +190,9 @@ public sealed class GrpcAiClassifierTests
|
|||||||
Ok = false,
|
Ok = false,
|
||||||
Usage = new Usage { Prompt = 900, Completion = 0, Total = 900 },
|
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);
|
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits);
|
||||||
|
|
||||||
// ok=false (модель без JSON) → AiUnavailableException: воркер падает в локальный разбор (aiFail).
|
// ok=false (модель без JSON) → AiUnavailableException: воркер падает в локальный разбор (aiFail).
|
||||||
@@ -212,7 +211,7 @@ public sealed class GrpcAiClassifierTests
|
|||||||
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
|
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
|
||||||
{
|
{
|
||||||
service.ClassifyUnavailable = true;
|
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);
|
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
|
||||||
|
|
||||||
await Assert.ThrowsAsync<AiUnavailableException>(
|
await Assert.ThrowsAsync<AiUnavailableException>(
|
||||||
@@ -226,7 +225,7 @@ public sealed class GrpcAiClassifierTests
|
|||||||
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
|
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
|
||||||
{
|
{
|
||||||
service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Заголовок","stack":[],"is_spam":false}""" };
|
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.AiProvider, Json("anthropic"));
|
||||||
settings.Preload(
|
settings.Preload(
|
||||||
SettingsKeys.AiConfigs,
|
SettingsKeys.AiConfigs,
|
||||||
@@ -263,7 +262,7 @@ public sealed class GrpcAiClassifierTests
|
|||||||
{
|
{
|
||||||
service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Т","stack":[],"is_spam":false}""" };
|
service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Т","stack":[],"is_spam":false}""" };
|
||||||
string text = new string('б', 6000);
|
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);
|
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
|
||||||
|
|
||||||
await classifier.ClassifyAsync(text, CancellationToken.None);
|
await classifier.ClassifyAsync(text, CancellationToken.None);
|
||||||
@@ -284,28 +283,29 @@ public sealed class GrpcAiClassifierTests
|
|||||||
// Возвращает: Экземпляр GrpcAiClassifier.
|
// Возвращает: Экземпляр GrpcAiClassifier.
|
||||||
private static GrpcAiClassifier CreateClassifier(
|
private static GrpcAiClassifier CreateClassifier(
|
||||||
int port,
|
int port,
|
||||||
FakeSettingsStore settings,
|
TestSettingsStore settings,
|
||||||
ISecretCipher cipher,
|
ISecretCipher cipher,
|
||||||
FakeKanjStore kanjStore,
|
TestKanjStore kanjStore,
|
||||||
FakeTenantLimitStore? limits = null)
|
TestTenantLimitStore? limits = null)
|
||||||
{
|
{
|
||||||
|
limits ??= new TestTenantLimitStore();
|
||||||
ITenantContext tenantContext = new TenantContext();
|
ITenantContext tenantContext = new TenantContext();
|
||||||
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
||||||
var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
|
var connection = new AiGrpcConnection(new AiServiceOptions { UseLocal = false, Endpoint = $"http://127.0.0.1:{port}" });
|
||||||
return new GrpcAiClassifier(
|
return new GrpcAiClassifier(
|
||||||
tenantContext,
|
tenantContext,
|
||||||
connection,
|
connection,
|
||||||
new AiProviderConfigBuilder(settings, cipher),
|
new AiProviderConfigBuilder(settings.Store, cipher),
|
||||||
new AiClassifyContextBuilder(settings, kanjStore),
|
new AiClassifyContextBuilder(settings.Store, kanjStore.Store),
|
||||||
new TokenUsageRecorder(settings, limits ?? new FakeTenantLimitStore(), tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
|
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
|
||||||
NullLogger<GrpcAiClassifier>.Instance);
|
NullLogger<GrpcAiClassifier>.Instance);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Создаёт контекст сценария (пустые фейки; сценарий переопределяет настройки/доски).
|
// Создаёт контекст сценария (пустые фейки; сценарий переопределяет настройки/доски).
|
||||||
// port: Порт хоста (не используется контекстом — единый вид хелперов).
|
// port: Порт хоста (не используется контекстом — единый вид хелперов).
|
||||||
// Возвращает: Кортеж фейков (настройки, шифр, канбан).
|
// Возвращает: Кортеж фейков (настройки, шифр, канбан).
|
||||||
private static (FakeSettingsStore Settings, ISecretCipher Cipher, FakeKanjStore Kanj) Context(int port)
|
private static (TestSettingsStore Settings, ISecretCipher Cipher, TestKanjStore Kanj) Context(int port)
|
||||||
=> (new FakeSettingsStore(), TestCiphers.New(), new FakeKanjStore());
|
=> (new TestSettingsStore(), TestCiphers.New(), new TestKanjStore());
|
||||||
|
|
||||||
// Сериализует значение настройки в JSON-строку (как пишет SettingsStore).
|
// Сериализует значение настройки в JSON-строку (как пишет SettingsStore).
|
||||||
// value: Значение (строка/список).
|
// value: Значение (строка/список).
|
||||||
@@ -313,7 +313,7 @@ public sealed class GrpcAiClassifierTests
|
|||||||
private static string Json(object value) => JsonSerializer.Serialize(value);
|
private static string Json(object value) => JsonSerializer.Serialize(value);
|
||||||
|
|
||||||
private static async Task AssertUsageAsync(
|
private static async Task AssertUsageAsync(
|
||||||
FakeSettingsStore settings,
|
TestSettingsStore settings,
|
||||||
uint prompt,
|
uint prompt,
|
||||||
uint completion,
|
uint completion,
|
||||||
uint total)
|
uint total)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
|
using Deal.Tests.Unit.Support;
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Support;
|
namespace Deal.Tests.Unit.Support;
|
||||||
@@ -12,7 +13,7 @@ public sealed class IncomingRulesTests
|
|||||||
// Создаёт правила поверх in-memory KV-хранилища.
|
// Создаёт правила поверх in-memory KV-хранилища.
|
||||||
// store: Хранилище настроек тенанта.
|
// store: Хранилище настроек тенанта.
|
||||||
// Возвращает: Экземпляр IncomingRules.
|
// Возвращает: Экземпляр IncomingRules.
|
||||||
private static IncomingRules CreateRules(FakeSettingsStore store) => new(store);
|
private static IncomingRules CreateRules(TestSettingsStore store) => new(store.Store);
|
||||||
|
|
||||||
// Сериализует значение в JSON-строку строки settings (как пишет SettingsStore).
|
// Сериализует значение в JSON-строку строки settings (как пишет SettingsStore).
|
||||||
// value: Значение (строка/число/булево/список).
|
// value: Значение (строка/число/булево/список).
|
||||||
@@ -23,7 +24,7 @@ public sealed class IncomingRulesTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CheckAsync_ShortTextBelowDefaultMinLen_FailsWithKindLength()
|
public async Task CheckAsync_ShortTextBelowDefaultMinLen_FailsWithKindLength()
|
||||||
{
|
{
|
||||||
IncomingRulesResult result = await CreateRules(new FakeSettingsStore())
|
IncomingRulesResult result = await CreateRules(new TestSettingsStore())
|
||||||
.CheckAsync("Короткий текст", CancellationToken.None);
|
.CheckAsync("Короткий текст", CancellationToken.None);
|
||||||
|
|
||||||
Assert.False(result.Pass);
|
Assert.False(result.Pass);
|
||||||
@@ -37,7 +38,7 @@ public sealed class IncomingRulesTests
|
|||||||
public async Task CheckAsync_EmptyText_IsLengthFail()
|
public async Task CheckAsync_EmptyText_IsLengthFail()
|
||||||
{
|
{
|
||||||
// (text or "").strip() — пустой/пробельный текст всегда «короче minLen».
|
// (text or "").strip() — пустой/пробельный текст всегда «короче minLen».
|
||||||
IncomingRulesResult result = await CreateRules(new FakeSettingsStore())
|
IncomingRulesResult result = await CreateRules(new TestSettingsStore())
|
||||||
.CheckAsync(" ", CancellationToken.None);
|
.CheckAsync(" ", CancellationToken.None);
|
||||||
|
|
||||||
Assert.False(result.Pass);
|
Assert.False(result.Pass);
|
||||||
@@ -47,7 +48,7 @@ public sealed class IncomingRulesTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CheckAsync_StoredMinLenShorter_AllowsTextBetweenDefaultsAndOverride()
|
public async Task CheckAsync_StoredMinLenShorter_AllowsTextBetweenDefaultsAndOverride()
|
||||||
{
|
{
|
||||||
var store = new FakeSettingsStore();
|
var store = new TestSettingsStore();
|
||||||
store.Preload(SettingsKeys.MinLen, Json(10));
|
store.Preload(SettingsKeys.MinLen, Json(10));
|
||||||
IncomingRules rules = CreateRules(store);
|
IncomingRules rules = CreateRules(store);
|
||||||
|
|
||||||
@@ -61,7 +62,7 @@ public sealed class IncomingRulesTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CheckAsync_LongCleanTextWithDefaults_Passes()
|
public async Task CheckAsync_LongCleanTextWithDefaults_Passes()
|
||||||
{
|
{
|
||||||
IncomingRulesResult result = await CreateRules(new FakeSettingsStore()).CheckAsync(
|
IncomingRulesResult result = await CreateRules(new TestSettingsStore()).CheckAsync(
|
||||||
"Заработок на крипте 300% в месяц! Подпишись на канал и получи бесплатный курс по трейдингу",
|
"Заработок на крипте 300% в месяц! Подпишись на канал и получи бесплатный курс по трейдингу",
|
||||||
CancellationToken.None);
|
CancellationToken.None);
|
||||||
|
|
||||||
@@ -75,7 +76,7 @@ public sealed class IncomingRulesTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CheckAsync_StopPhraseHit_ReturnsPhraseAsKwAndReason()
|
public async Task CheckAsync_StopPhraseHit_ReturnsPhraseAsKwAndReason()
|
||||||
{
|
{
|
||||||
var store = new FakeSettingsStore();
|
var store = new TestSettingsStore();
|
||||||
store.Preload(SettingsKeys.StopPhrases, Json(new[] { "взаимный пиар" }));
|
store.Preload(SettingsKeys.StopPhrases, Json(new[] { "взаимный пиар" }));
|
||||||
IncomingRules rules = CreateRules(store);
|
IncomingRules rules = CreateRules(store);
|
||||||
|
|
||||||
@@ -92,7 +93,7 @@ public sealed class IncomingRulesTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CheckAsync_StopPhrase_CaseInsensitiveMatch_KwKeepsStoredCase()
|
public async Task CheckAsync_StopPhrase_CaseInsensitiveMatch_KwKeepsStoredCase()
|
||||||
{
|
{
|
||||||
var store = new FakeSettingsStore();
|
var store = new TestSettingsStore();
|
||||||
store.Preload(SettingsKeys.StopPhrases, Json(new[] { "Взаимный Пиар" }));
|
store.Preload(SettingsKeys.StopPhrases, Json(new[] { "Взаимный Пиар" }));
|
||||||
IncomingRules rules = CreateRules(store);
|
IncomingRules rules = CreateRules(store);
|
||||||
|
|
||||||
@@ -108,7 +109,7 @@ public sealed class IncomingRulesTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CheckAsync_ResumeMarker_BlockResumesOn_BlocksWithMarker()
|
public async Task CheckAsync_ResumeMarker_BlockResumesOn_BlocksWithMarker()
|
||||||
{
|
{
|
||||||
var store = new FakeSettingsStore();
|
var store = new TestSettingsStore();
|
||||||
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
|
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
|
||||||
IncomingRules rules = CreateRules(store);
|
IncomingRules rules = CreateRules(store);
|
||||||
|
|
||||||
@@ -125,7 +126,7 @@ public sealed class IncomingRulesTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CheckAsync_ResumeText_BlockResumesOff_Passes()
|
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.StopPhrases, Json(Array.Empty<string>()));
|
||||||
store.Preload(SettingsKeys.BlockResumes, Json(false));
|
store.Preload(SettingsKeys.BlockResumes, Json(false));
|
||||||
IncomingRules rules = CreateRules(store);
|
IncomingRules rules = CreateRules(store);
|
||||||
@@ -141,7 +142,7 @@ public sealed class IncomingRulesTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CheckAsync_ResumeWordWithoutHireMarkerBefore_IsCut()
|
public async Task CheckAsync_ResumeWordWithoutHireMarkerBefore_IsCut()
|
||||||
{
|
{
|
||||||
var store = new FakeSettingsStore();
|
var store = new TestSettingsStore();
|
||||||
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
|
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
|
||||||
IncomingRules rules = CreateRules(store);
|
IncomingRules rules = CreateRules(store);
|
||||||
|
|
||||||
@@ -158,7 +159,7 @@ public sealed class IncomingRulesTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CheckAsync_VacancyWithResumeWordAfterHireMarker_GuardAllowsPass()
|
public async Task CheckAsync_VacancyWithResumeWordAfterHireMarker_GuardAllowsPass()
|
||||||
{
|
{
|
||||||
var store = new FakeSettingsStore();
|
var store = new TestSettingsStore();
|
||||||
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
|
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
|
||||||
IncomingRules rules = CreateRules(store);
|
IncomingRules rules = CreateRules(store);
|
||||||
|
|
||||||
@@ -175,7 +176,7 @@ public sealed class IncomingRulesTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CheckAsync_WantedTypeFreelance_VacancyLikeText_BlockedAsType()
|
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.StopPhrases, Json(Array.Empty<string>()));
|
||||||
store.Preload(SettingsKeys.WantedType, Json("freelance"));
|
store.Preload(SettingsKeys.WantedType, Json("freelance"));
|
||||||
IncomingRules rules = CreateRules(store);
|
IncomingRules rules = CreateRules(store);
|
||||||
@@ -194,7 +195,7 @@ public sealed class IncomingRulesTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CheckAsync_WantedTypeVacancy_OrderLikeText_BlockedAsType()
|
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.StopPhrases, Json(Array.Empty<string>()));
|
||||||
store.Preload(SettingsKeys.WantedType, Json("vacancy"));
|
store.Preload(SettingsKeys.WantedType, Json("vacancy"));
|
||||||
IncomingRules rules = CreateRules(store);
|
IncomingRules rules = CreateRules(store);
|
||||||
@@ -211,7 +212,7 @@ public sealed class IncomingRulesTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CheckAsync_WantedTypeFreelance_OrderText_Passes()
|
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.StopPhrases, Json(Array.Empty<string>()));
|
||||||
store.Preload(SettingsKeys.WantedType, Json("freelance"));
|
store.Preload(SettingsKeys.WantedType, Json("freelance"));
|
||||||
IncomingRules rules = CreateRules(store);
|
IncomingRules rules = CreateRules(store);
|
||||||
@@ -229,7 +230,7 @@ public sealed class IncomingRulesTests
|
|||||||
public async Task CheckAsync_WantedTypeBoth_VacancyText_Passes()
|
public async Task CheckAsync_WantedTypeBoth_VacancyText_Passes()
|
||||||
{
|
{
|
||||||
// Дефолт wantedType=both: и вакансии, и заказы — вакансионный текст не отсекается.
|
// Дефолт wantedType=both: и вакансии, и заказы — вакансионный текст не отсекается.
|
||||||
var store = new FakeSettingsStore();
|
var store = new TestSettingsStore();
|
||||||
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
|
store.Preload(SettingsKeys.StopPhrases, Json(Array.Empty<string>()));
|
||||||
IncomingRules rules = CreateRules(store);
|
IncomingRules rules = CreateRules(store);
|
||||||
|
|
||||||
|
|||||||
@@ -37,10 +37,10 @@ public sealed class JoinEndpointHttpTests
|
|||||||
{
|
{
|
||||||
var inviteStore = new TestInviteStore();
|
var inviteStore = new TestInviteStore();
|
||||||
inviteStore.AddInvite(NewInvite(Email));
|
inviteStore.AddInvite(NewInvite(Email));
|
||||||
var authStore = new FakeAuthStore();
|
var authStore = new TestAuthStore();
|
||||||
var tenantStore = new TestTenantStore();
|
var tenantStore = new TestTenantStore();
|
||||||
var provisioner = new TestTenantProvisioner();
|
var provisioner = new TestTenantProvisioner();
|
||||||
var auditStore = new FakeAuditLogStore();
|
var auditStore = new TestAuditLogStore();
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
inviteStore, tenantStore, provisioner.Provisioner, authStore, auditStore,
|
inviteStore, tenantStore, provisioner.Provisioner, authStore, auditStore,
|
||||||
@@ -56,7 +56,7 @@ public sealed class JoinEndpointHttpTests
|
|||||||
Assert.False(response.Headers.Contains("Set-Cookie"));
|
Assert.False(response.Headers.Contains("Set-Cookie"));
|
||||||
});
|
});
|
||||||
|
|
||||||
// Пользователь создан (login=email, хэш через фейк-хэшер), тенант создан и провижинен один раз, инвайт activated.
|
// Пользователь создан (login=email, хэш через подставку-хэшер), тенант создан и провижинен один раз, инвайт activated.
|
||||||
StoredUserDto user = Assert.Single(authStore.Users);
|
StoredUserDto user = Assert.Single(authStore.Users);
|
||||||
Assert.Equal(Email, user.Login);
|
Assert.Equal(Email, user.Login);
|
||||||
Assert.Equal(TestHashers.New().Hash(Password), user.PasswordHash);
|
Assert.Equal(TestHashers.New().Hash(Password), user.PasswordHash);
|
||||||
@@ -80,10 +80,10 @@ public sealed class JoinEndpointHttpTests
|
|||||||
{
|
{
|
||||||
var inviteStore = new TestInviteStore();
|
var inviteStore = new TestInviteStore();
|
||||||
inviteStore.AddInvite(NewInvite(Email));
|
inviteStore.AddInvite(NewInvite(Email));
|
||||||
var authStore = new FakeAuthStore();
|
var authStore = new TestAuthStore();
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(),
|
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new TestAuditLogStore(),
|
||||||
async (baseAddress, client) =>
|
async (baseAddress, client) =>
|
||||||
{
|
{
|
||||||
using (HttpResponseMessage first = await PostJsonAsync(
|
using (HttpResponseMessage first = await PostJsonAsync(
|
||||||
@@ -108,10 +108,10 @@ public sealed class JoinEndpointHttpTests
|
|||||||
{
|
{
|
||||||
var inviteStore = new TestInviteStore();
|
var inviteStore = new TestInviteStore();
|
||||||
inviteStore.AddInvite(NewInvite(Email));
|
inviteStore.AddInvite(NewInvite(Email));
|
||||||
var authStore = new FakeAuthStore();
|
var authStore = new TestAuthStore();
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(),
|
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new TestAuditLogStore(),
|
||||||
async (baseAddress, client) =>
|
async (baseAddress, client) =>
|
||||||
{
|
{
|
||||||
using HttpResponseMessage response = await PostJsonAsync(
|
using HttpResponseMessage response = await PostJsonAsync(
|
||||||
@@ -132,7 +132,7 @@ public sealed class JoinEndpointHttpTests
|
|||||||
inviteStore.AddInvite(NewInvite(Email, status: InviteStatuses.Revoked));
|
inviteStore.AddInvite(NewInvite(Email, status: InviteStatuses.Revoked));
|
||||||
|
|
||||||
await RunAsync(
|
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) =>
|
async (baseAddress, client) =>
|
||||||
{
|
{
|
||||||
using HttpResponseMessage response = await PostJsonAsync(
|
using HttpResponseMessage response = await PostJsonAsync(
|
||||||
@@ -150,7 +150,7 @@ public sealed class JoinEndpointHttpTests
|
|||||||
inviteStore.AddInvite(NewInvite(Email, expiresAt: DateTimeOffset.UtcNow.AddHours(-1)));
|
inviteStore.AddInvite(NewInvite(Email, expiresAt: DateTimeOffset.UtcNow.AddHours(-1)));
|
||||||
|
|
||||||
await RunAsync(
|
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) =>
|
async (baseAddress, client) =>
|
||||||
{
|
{
|
||||||
using HttpResponseMessage response = await PostJsonAsync(
|
using HttpResponseMessage response = await PostJsonAsync(
|
||||||
@@ -165,7 +165,7 @@ public sealed class JoinEndpointHttpTests
|
|||||||
public async Task Join_WithUnknownCode_Returns400NotFound()
|
public async Task Join_WithUnknownCode_Returns400NotFound()
|
||||||
{
|
{
|
||||||
await RunAsync(
|
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) =>
|
async (baseAddress, client) =>
|
||||||
{
|
{
|
||||||
using HttpResponseMessage response = await PostJsonAsync(
|
using HttpResponseMessage response = await PostJsonAsync(
|
||||||
@@ -183,7 +183,7 @@ public sealed class JoinEndpointHttpTests
|
|||||||
inviteStore.AddInvite(NewInvite(Email));
|
inviteStore.AddInvite(NewInvite(Email));
|
||||||
|
|
||||||
await RunAsync(
|
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) =>
|
async (baseAddress, client) =>
|
||||||
{
|
{
|
||||||
using HttpResponseMessage response = await PostJsonAsync(
|
using HttpResponseMessage response = await PostJsonAsync(
|
||||||
@@ -201,11 +201,11 @@ public sealed class JoinEndpointHttpTests
|
|||||||
{
|
{
|
||||||
var inviteStore = new TestInviteStore();
|
var inviteStore = new TestInviteStore();
|
||||||
inviteStore.AddInvite(NewInvite(Email));
|
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"));
|
authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: "active", "hash"));
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new FakeAuditLogStore(),
|
inviteStore, new TestTenantStore(), new TestTenantProvisioner().Provisioner, authStore, new TestAuditLogStore(),
|
||||||
async (baseAddress, client) =>
|
async (baseAddress, client) =>
|
||||||
{
|
{
|
||||||
using HttpResponseMessage response = await PostJsonAsync(
|
using HttpResponseMessage response = await PostJsonAsync(
|
||||||
@@ -228,10 +228,10 @@ public sealed class JoinEndpointHttpTests
|
|||||||
// Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему.
|
// Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему.
|
||||||
var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow));
|
var tenantStore = new TestTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow));
|
||||||
var provisioner = new TestTenantProvisioner();
|
var provisioner = new TestTenantProvisioner();
|
||||||
var authStore = new FakeAuthStore();
|
var authStore = new TestAuthStore();
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
inviteStore, tenantStore, provisioner.Provisioner, authStore, new FakeAuditLogStore(),
|
inviteStore, tenantStore, provisioner.Provisioner, authStore, new TestAuditLogStore(),
|
||||||
async (baseAddress, client) =>
|
async (baseAddress, client) =>
|
||||||
{
|
{
|
||||||
using HttpResponseMessage response = await PostJsonAsync(
|
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(
|
private static async Task RunAsync(
|
||||||
TestInviteStore inviteStore,
|
TestInviteStore inviteStore,
|
||||||
TestTenantStore tenantStore,
|
TestTenantStore tenantStore,
|
||||||
ITenantProvisioner provisioner,
|
ITenantProvisioner provisioner,
|
||||||
FakeAuthStore authStore,
|
TestAuthStore authStore,
|
||||||
FakeAuditLogStore auditStore,
|
TestAuditLogStore auditStore,
|
||||||
Func<string, HttpClient, Task> scenario)
|
Func<string, HttpClient, Task> scenario)
|
||||||
{
|
{
|
||||||
int port = TestPort.Allocate();
|
int port = TestPort.Allocate();
|
||||||
@@ -264,8 +264,8 @@ public sealed class JoinEndpointHttpTests
|
|||||||
// Хэшер-подставка регистрируется ПОСЛЕ AddTenantsModule (DefaultPasswordHasher) — побеждает
|
// Хэшер-подставка регистрируется ПОСЛЕ AddTenantsModule (DefaultPasswordHasher) — побеждает
|
||||||
// последняя регистрация (зеркало OperatorAuthHttpHost).
|
// последняя регистрация (зеркало OperatorAuthHttpHost).
|
||||||
builder.Services.AddSingleton(TestHashers.New());
|
builder.Services.AddSingleton(TestHashers.New());
|
||||||
builder.Services.AddSingleton<IAuthStore>(authStore);
|
builder.Services.AddSingleton<IAuthStore>(authStore.Store);
|
||||||
builder.Services.AddSingleton<IAuditLogStore>(auditStore);
|
builder.Services.AddSingleton<IAuditLogStore>(auditStore.Store);
|
||||||
builder.Services.AddSingleton<IInviteStore>(inviteStore.Store);
|
builder.Services.AddSingleton<IInviteStore>(inviteStore.Store);
|
||||||
builder.Services.AddSingleton<ITenantRepository>(tenantStore.Repository);
|
builder.Services.AddSingleton<ITenantRepository>(tenantStore.Repository);
|
||||||
builder.Services.AddSingleton<ITenantProvisioner>(provisioner);
|
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.Models;
|
||||||
using Deal.Modules.Kanban.Application.Services;
|
using Deal.Modules.Kanban.Application.Services;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
using Deal.Tests.Unit.Modules.Cards;
|
using Deal.Tests.Unit.Support;
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Support;
|
namespace Deal.Tests.Unit.Support;
|
||||||
@@ -17,18 +17,18 @@ public sealed class LocalColumnSuggesterTests
|
|||||||
{
|
{
|
||||||
// Создаёт контекст теста: in-memory хранилища + адаптер поверх сервиса досок модуля.
|
// Создаёт контекст теста: in-memory хранилища + адаптер поверх сервиса досок модуля.
|
||||||
// Возвращает: Кортеж (хранилище канбана, KV-настройки, адаптер).
|
// Возвращает: Кортеж (хранилище канбана, KV-настройки, адаптер).
|
||||||
private static (FakeKanjStore Store, FakeSettingsStore Settings, LocalColumnSuggester Suggester) CreateContext()
|
private static (TestKanjStore Store, TestSettingsStore Settings, LocalColumnSuggester Suggester) CreateContext()
|
||||||
{
|
{
|
||||||
var store = new FakeKanjStore();
|
var store = new TestKanjStore();
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
var suggester = new LocalColumnSuggester(store, settings, new ContainersService(store, settings));
|
var suggester = new LocalColumnSuggester(store.Store, settings.Store, new ContainersService(store.Store, settings.Store));
|
||||||
return (store, settings, suggester);
|
return (store, settings, suggester);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void SeedInbox(
|
private static void SeedInbox(
|
||||||
string id,
|
string id,
|
||||||
string text,
|
string text,
|
||||||
FakeKanjStore store,
|
TestKanjStore store,
|
||||||
long receivedAtMs = 0) =>
|
long receivedAtMs = 0) =>
|
||||||
store.SeedCard(new CardDto
|
store.SeedCard(new CardDto
|
||||||
{
|
{
|
||||||
@@ -57,7 +57,7 @@ public sealed class LocalColumnSuggesterTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task SuggestColumns_TooFewCards_ReturnsTooFewCardsReason()
|
public async Task SuggestColumns_TooFewCards_ReturnsTooFewCardsReason()
|
||||||
{
|
{
|
||||||
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
||||||
for (int i = 1; i < SuggestHeuristics.MinInbox; i++)
|
for (int i = 1; i < SuggestHeuristics.MinInbox; i++)
|
||||||
{
|
{
|
||||||
SeedInbox($"l_{i}", "нужен python", store);
|
SeedInbox($"l_{i}", "нужен python", store);
|
||||||
@@ -72,7 +72,7 @@ public sealed class LocalColumnSuggesterTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task SuggestColumns_CooldownActive_ReturnsCooldownReason()
|
public async Task SuggestColumns_CooldownActive_ReturnsCooldownReason()
|
||||||
{
|
{
|
||||||
(FakeKanjStore store, FakeSettingsStore settings, LocalColumnSuggester suggester) = CreateContext();
|
(TestKanjStore store, TestSettingsStore settings, LocalColumnSuggester suggester) = CreateContext();
|
||||||
settings.Preload(SettingsKeys.LastSuggestAt,
|
settings.Preload(SettingsKeys.LastSuggestAt,
|
||||||
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
|
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
|
||||||
SeedInbox("l_1", "нужен python", store);
|
SeedInbox("l_1", "нужен python", store);
|
||||||
@@ -87,7 +87,7 @@ public sealed class LocalColumnSuggesterTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task SuggestColumns_NoThemes_ReturnsNothingGroupedReason()
|
public async Task SuggestColumns_NoThemes_ReturnsNothingGroupedReason()
|
||||||
{
|
{
|
||||||
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
||||||
string[] uniqueWords = ["python", "vue", "crm", "sql", "php", "mvp"];
|
string[] uniqueWords = ["python", "vue", "crm", "sql", "php", "mvp"];
|
||||||
for (int i = 0; i < uniqueWords.Length; i++)
|
for (int i = 0; i < uniqueWords.Length; i++)
|
||||||
{
|
{
|
||||||
@@ -103,7 +103,7 @@ public sealed class LocalColumnSuggesterTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task SuggestColumns_SimilarExistingBoard_ReturnsNothingGroupedReason()
|
public async Task SuggestColumns_SimilarExistingBoard_ReturnsNothingGroupedReason()
|
||||||
{
|
{
|
||||||
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
||||||
store.SeedBoard(new ContainerDto
|
store.SeedBoard(new ContainerDto
|
||||||
{
|
{
|
||||||
Id = "b_python",
|
Id = "b_python",
|
||||||
@@ -128,7 +128,7 @@ public sealed class LocalColumnSuggesterTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task SuggestColumns_Success_CreatesSuggestedBoardsAndPlacesCards()
|
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_p1", "нужен python", store);
|
||||||
SeedInbox("l_p2", "нужен python", store);
|
SeedInbox("l_p2", "нужен python", store);
|
||||||
SeedInbox("l_p3", "нужен python", store);
|
SeedInbox("l_p3", "нужен python", store);
|
||||||
@@ -169,7 +169,7 @@ public sealed class LocalColumnSuggesterTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task SuggestColumns_SuccessThenImmediateRepeat_BlockedByCooldown()
|
public async Task SuggestColumns_SuccessThenImmediateRepeat_BlockedByCooldown()
|
||||||
{
|
{
|
||||||
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
||||||
SeedInbox("l_p1", "нужен python", store);
|
SeedInbox("l_p1", "нужен python", store);
|
||||||
SeedInbox("l_p2", "нужен python", store);
|
SeedInbox("l_p2", "нужен python", store);
|
||||||
SeedInbox("l_p3", "нужен python", store);
|
SeedInbox("l_p3", "нужен python", store);
|
||||||
@@ -191,7 +191,7 @@ public sealed class LocalColumnSuggesterTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task SuggestKeywords_Success_ReturnsRepeatedWordMarkers()
|
public async Task SuggestKeywords_Success_ReturnsRepeatedWordMarkers()
|
||||||
{
|
{
|
||||||
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
||||||
SeedInbox("l_p1", "нужен python", store);
|
SeedInbox("l_p1", "нужен python", store);
|
||||||
SeedInbox("l_p2", "нужен python", store);
|
SeedInbox("l_p2", "нужен python", store);
|
||||||
SeedInbox("l_p3", "нужен python", store);
|
SeedInbox("l_p3", "нужен python", store);
|
||||||
@@ -209,7 +209,7 @@ public sealed class LocalColumnSuggesterTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task SuggestKeywords_TooFewCards_ReturnsReason()
|
public async Task SuggestKeywords_TooFewCards_ReturnsReason()
|
||||||
{
|
{
|
||||||
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
||||||
SeedInbox("l_1", "нужен python", store);
|
SeedInbox("l_1", "нужен python", store);
|
||||||
SeedInbox("l_2", "нужен python", store);
|
SeedInbox("l_2", "нужен python", store);
|
||||||
|
|
||||||
@@ -223,7 +223,7 @@ public sealed class LocalColumnSuggesterTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task SuggestKeywords_TrashAndArchiveCards_AreNotCounted()
|
public async Task SuggestKeywords_TrashAndArchiveCards_AreNotCounted()
|
||||||
{
|
{
|
||||||
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
||||||
SeedInbox("l_1", "нужен python", store);
|
SeedInbox("l_1", "нужен python", store);
|
||||||
SeedInbox("l_2", "нужен vue", store);
|
SeedInbox("l_2", "нужен vue", store);
|
||||||
store.SeedCard(new CardDto { Id = "l_t1", Col = KanbanColumns.Trash, Content = new SourceContent { Text = "ищу такси" } });
|
store.SeedCard(new CardDto { Id = "l_t1", Col = KanbanColumns.Trash, Content = new SourceContent { Text = "ищу такси" } });
|
||||||
@@ -239,7 +239,7 @@ public sealed class LocalColumnSuggesterTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task SuggestKeywords_NoRepeatedMarkers_ReturnsReason()
|
public async Task SuggestKeywords_NoRepeatedMarkers_ReturnsReason()
|
||||||
{
|
{
|
||||||
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
|
||||||
string[] uniqueWords = ["python", "vue", "crm", "sql", "php", "mvp"];
|
string[] uniqueWords = ["python", "vue", "crm", "sql", "php", "mvp"];
|
||||||
for (int i = 0; i < uniqueWords.Length; i++)
|
for (int i = 0; i < uniqueWords.Length; i++)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Deal.Contracts.Integrations.Models;
|
|||||||
using Deal.Infrastructure.Integrations.Services;
|
using Deal.Infrastructure.Integrations.Services;
|
||||||
using Deal.Modules.Kanban.Application.Models;
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
|
using Deal.Tests.Unit.Support;
|
||||||
using Deal.Tests.Unit.Modules.Kanban;
|
using Deal.Tests.Unit.Modules.Kanban;
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
|
|
||||||
@@ -27,14 +28,17 @@ public sealed class LocalMlClientTests
|
|||||||
// store: Хранилище настроек тенанта.
|
// store: Хранилище настроек тенанта.
|
||||||
// learning: Хранилище обучения (по умолчанию пустое: счётчики 0).
|
// learning: Хранилище обучения (по умолчанию пустое: счётчики 0).
|
||||||
// Возвращает: Экземпляр LocalMlClient.
|
// Возвращает: Экземпляр LocalMlClient.
|
||||||
private static LocalMlClient CreateClient(FakeSettingsStore store, FakeMlLearningStore? learning = null)
|
private static LocalMlClient CreateClient(TestSettingsStore store, TestMlLearningStore? learning = null)
|
||||||
=> new(store, learning ?? new FakeMlLearningStore());
|
{
|
||||||
|
learning ??= new TestMlLearningStore();
|
||||||
|
return new(store.Store, learning.Store);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task StatusAsync_NoSettings_ReturnsDeterministicNotReadyStatus()
|
public async Task StatusAsync_NoSettings_ReturnsDeterministicNotReadyStatus()
|
||||||
{
|
{
|
||||||
LocalMlClient client = CreateClient(new FakeSettingsStore());
|
LocalMlClient client = CreateClient(new TestSettingsStore());
|
||||||
|
|
||||||
MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None);
|
MlStatusResponseDto status = await client.StatusAsync(CancellationToken.None);
|
||||||
|
|
||||||
@@ -63,7 +67,7 @@ public sealed class LocalMlClientTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task StatusAsync_StoredCounters_AreReflectedInStats()
|
public async Task StatusAsync_StoredCounters_AreReflectedInStats()
|
||||||
{
|
{
|
||||||
var store = new FakeSettingsStore();
|
var store = new TestSettingsStore();
|
||||||
store.Preload(SettingsKeys.MlDecisions, "7");
|
store.Preload(SettingsKeys.MlDecisions, "7");
|
||||||
store.Preload(SettingsKeys.AiDecisions, "3");
|
store.Preload(SettingsKeys.AiDecisions, "3");
|
||||||
LocalMlClient client = CreateClient(store);
|
LocalMlClient client = CreateClient(store);
|
||||||
@@ -78,7 +82,7 @@ public sealed class LocalMlClientTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task StatusAsync_StoredMlEnabledFalse_Disables()
|
public async Task StatusAsync_StoredMlEnabledFalse_Disables()
|
||||||
{
|
{
|
||||||
var store = new FakeSettingsStore();
|
var store = new TestSettingsStore();
|
||||||
store.Preload(SettingsKeys.MlEnabled, "false");
|
store.Preload(SettingsKeys.MlEnabled, "false");
|
||||||
|
|
||||||
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
|
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
|
||||||
@@ -89,7 +93,7 @@ public sealed class LocalMlClientTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task StatusAsync_StoredMlEnabledTrue_Enables()
|
public async Task StatusAsync_StoredMlEnabledTrue_Enables()
|
||||||
{
|
{
|
||||||
var store = new FakeSettingsStore();
|
var store = new TestSettingsStore();
|
||||||
store.Preload(SettingsKeys.MlEnabled, "true");
|
store.Preload(SettingsKeys.MlEnabled, "true");
|
||||||
|
|
||||||
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
|
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
|
||||||
@@ -102,7 +106,7 @@ public sealed class LocalMlClientTests
|
|||||||
[InlineData("не json {{")]
|
[InlineData("не json {{")]
|
||||||
public async Task StatusAsync_NonBoolOrDamagedMlEnabled_DegradesToDefault(string valueJson)
|
public async Task StatusAsync_NonBoolOrDamagedMlEnabled_DegradesToDefault(string valueJson)
|
||||||
{
|
{
|
||||||
var store = new FakeSettingsStore();
|
var store = new TestSettingsStore();
|
||||||
store.Preload(SettingsKeys.MlEnabled, valueJson);
|
store.Preload(SettingsKeys.MlEnabled, valueJson);
|
||||||
|
|
||||||
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
|
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
|
||||||
@@ -113,7 +117,7 @@ public sealed class LocalMlClientTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task StatusAsync_DamagedCounter_DegradesToZero()
|
public async Task StatusAsync_DamagedCounter_DegradesToZero()
|
||||||
{
|
{
|
||||||
var store = new FakeSettingsStore();
|
var store = new TestSettingsStore();
|
||||||
store.Preload(SettingsKeys.MlDecisions, "не число");
|
store.Preload(SettingsKeys.MlDecisions, "не число");
|
||||||
|
|
||||||
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
|
MlStatusResponseDto status = await CreateClient(store).StatusAsync(CancellationToken.None);
|
||||||
@@ -124,8 +128,8 @@ public sealed class LocalMlClientTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task StatusAsync_ReportsLearningAndOutboxFromTables()
|
public async Task StatusAsync_ReportsLearningAndOutboxFromTables()
|
||||||
{
|
{
|
||||||
var store = new FakeSettingsStore();
|
var store = new TestSettingsStore();
|
||||||
var learning = new FakeMlLearningStore { LearningCount = 3 }; // журнал CardMoves: 3 записи
|
var learning = new TestMlLearningStore { LearningCount = 3 }; // журнал CardMoves: 3 записи
|
||||||
LocalMlClient client = CreateClient(store, learning);
|
LocalMlClient client = CreateClient(store, learning);
|
||||||
await client.PushAsync("Разработчик Python, готов к проекту", "b_alpha", 1.0, CancellationToken.None);
|
await client.PushAsync("Разработчик Python, готов к проекту", "b_alpha", 1.0, CancellationToken.None);
|
||||||
await client.PushAsync("Спам: купите курсы", "spam", -1.0, CancellationToken.None);
|
await client.PushAsync("Спам: купите курсы", "spam", -1.0, CancellationToken.None);
|
||||||
@@ -142,7 +146,7 @@ public sealed class LocalMlClientTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PredictAsync_AnyText_ReturnsFixedNotReadyShape()
|
public async Task PredictAsync_AnyText_ReturnsFixedNotReadyShape()
|
||||||
{
|
{
|
||||||
LocalMlClient client = CreateClient(new FakeSettingsStore());
|
LocalMlClient client = CreateClient(new TestSettingsStore());
|
||||||
|
|
||||||
MlPredictResultDto result = await client.PredictAsync(
|
MlPredictResultDto result = await client.PredictAsync(
|
||||||
"Python backend на fastapi, бот в телеграм", CancellationToken.None);
|
"Python backend на fastapi, бот в телеграм", CancellationToken.None);
|
||||||
@@ -161,8 +165,8 @@ public sealed class LocalMlClientTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PushAsync_WritesOutboxRow_WithMleIdAndTrimmedFields()
|
public async Task PushAsync_WritesOutboxRow_WithMleIdAndTrimmedFields()
|
||||||
{
|
{
|
||||||
var learning = new FakeMlLearningStore();
|
var learning = new TestMlLearningStore();
|
||||||
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning);
|
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
|
||||||
|
|
||||||
await client.PushAsync(" Python-разработчик, готов к работе ", " b_alpha ", 1.0, CancellationToken.None);
|
await client.PushAsync(" Python-разработчик, готов к работе ", " b_alpha ", 1.0, CancellationToken.None);
|
||||||
|
|
||||||
@@ -181,8 +185,8 @@ public sealed class LocalMlClientTests
|
|||||||
[InlineData("текст", " ")] // label из пробелов — пуста после trim
|
[InlineData("текст", " ")] // label из пробелов — пуста после trim
|
||||||
public async Task PushAsync_EmptyTextOrLabelAfterTrim_IsNoOp(string text, string label)
|
public async Task PushAsync_EmptyTextOrLabelAfterTrim_IsNoOp(string text, string label)
|
||||||
{
|
{
|
||||||
var learning = new FakeMlLearningStore();
|
var learning = new TestMlLearningStore();
|
||||||
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning);
|
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
|
||||||
|
|
||||||
await client.PushAsync(text, label, 1.0, CancellationToken.None);
|
await client.PushAsync(text, label, 1.0, CancellationToken.None);
|
||||||
|
|
||||||
@@ -192,8 +196,8 @@ public sealed class LocalMlClientTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PushAsync_TextOver6000_IsTruncatedToMaxLength()
|
public async Task PushAsync_TextOver6000_IsTruncatedToMaxLength()
|
||||||
{
|
{
|
||||||
var learning = new FakeMlLearningStore();
|
var learning = new TestMlLearningStore();
|
||||||
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning);
|
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
|
||||||
string longText = new string('а', MaxLearningTextLength + 1000);
|
string longText = new string('а', MaxLearningTextLength + 1000);
|
||||||
|
|
||||||
await client.PushAsync(longText, "spam", 1.0, CancellationToken.None);
|
await client.PushAsync(longText, "spam", 1.0, CancellationToken.None);
|
||||||
@@ -206,8 +210,8 @@ public sealed class LocalMlClientTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PushAsync_TruncationDoesNotSplitSurrogatePair()
|
public async Task PushAsync_TruncationDoesNotSplitSurrogatePair()
|
||||||
{
|
{
|
||||||
var learning = new FakeMlLearningStore();
|
var learning = new TestMlLearningStore();
|
||||||
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning);
|
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
|
||||||
// Длина 6001 в UTF-16: 5999 'x' + «🙂» (суррогатная пара) — срез .NET на 6000 попадает в пару.
|
// Длина 6001 в UTF-16: 5999 'x' + «🙂» (суррогатная пара) — срез .NET на 6000 попадает в пару.
|
||||||
string text = new string('x', MaxLearningTextLength - 1) + "🙂";
|
string text = new string('x', MaxLearningTextLength - 1) + "🙂";
|
||||||
|
|
||||||
@@ -222,8 +226,8 @@ public sealed class LocalMlClientTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PushAsync_NegativeDelta_IsStoredAsIs()
|
public async Task PushAsync_NegativeDelta_IsStoredAsIs()
|
||||||
{
|
{
|
||||||
var learning = new FakeMlLearningStore();
|
var learning = new TestMlLearningStore();
|
||||||
LocalMlClient client = CreateClient(new FakeSettingsStore(), learning);
|
LocalMlClient client = CreateClient(new TestSettingsStore(), learning);
|
||||||
|
|
||||||
await client.PushAsync("Это не спам — вернул из корзины", "spam", -1.0, CancellationToken.None);
|
await client.PushAsync("Это не спам — вернул из корзины", "spam", -1.0, CancellationToken.None);
|
||||||
|
|
||||||
@@ -236,7 +240,7 @@ public sealed class LocalMlClientTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ResetAsync_ReturnsOk()
|
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.True(result.Ok);
|
||||||
Assert.Null(result.Error);
|
Assert.Null(result.Error);
|
||||||
@@ -245,7 +249,7 @@ public sealed class LocalMlClientTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ResetAsync_DoesNotTouchSettingsRows()
|
public async Task ResetAsync_DoesNotTouchSettingsRows()
|
||||||
{
|
{
|
||||||
var store = new FakeSettingsStore();
|
var store = new TestSettingsStore();
|
||||||
store.Preload(SettingsKeys.MlDecisions, "7");
|
store.Preload(SettingsKeys.MlDecisions, "7");
|
||||||
LocalMlClient client = CreateClient(store);
|
LocalMlClient client = CreateClient(store);
|
||||||
|
|
||||||
@@ -259,9 +263,9 @@ public sealed class LocalMlClientTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ResetAsync_ClearsOutbox_KeepsLearningJournalAndKvCounters()
|
public async Task ResetAsync_ClearsOutbox_KeepsLearningJournalAndKvCounters()
|
||||||
{
|
{
|
||||||
var store = new FakeSettingsStore();
|
var store = new TestSettingsStore();
|
||||||
store.Preload(SettingsKeys.MlDecisions, "7");
|
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);
|
LocalMlClient client = CreateClient(store, learning);
|
||||||
await client.PushAsync("текст 1", "b_alpha", 1.0, CancellationToken.None);
|
await client.PushAsync("текст 1", "b_alpha", 1.0, CancellationToken.None);
|
||||||
await client.PushAsync("текст 2", "spam", -1.0, CancellationToken.None);
|
await client.PushAsync("текст 2", "spam", -1.0, CancellationToken.None);
|
||||||
@@ -278,7 +282,7 @@ public sealed class LocalMlClientTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task MlStatusResponseDto_SerializesToPrototypeWireFormat()
|
public async Task MlStatusResponseDto_SerializesToPrototypeWireFormat()
|
||||||
{
|
{
|
||||||
var store = new FakeSettingsStore();
|
var store = new TestSettingsStore();
|
||||||
store.Preload(SettingsKeys.MlEnabled, "false");
|
store.Preload(SettingsKeys.MlEnabled, "false");
|
||||||
store.Preload(SettingsKeys.MlDecisions, "7");
|
store.Preload(SettingsKeys.MlDecisions, "7");
|
||||||
store.Preload(SettingsKeys.AiDecisions, "3");
|
store.Preload(SettingsKeys.AiDecisions, "3");
|
||||||
@@ -316,7 +320,7 @@ public sealed class LocalMlClientTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task MlPredictResultDto_SerializesToPrototypeWireFormat()
|
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);
|
string json = JsonSerializer.Serialize(dto, CamelCaseOptions);
|
||||||
|
|
||||||
@@ -329,7 +333,7 @@ public sealed class LocalMlClientTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task MlResetResultDto_Success_SerializesWithoutErrorKey()
|
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);
|
string json = JsonSerializer.Serialize(dto, CamelCaseOptions);
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ public sealed class LoginAttemptEndpointHttpTests
|
|||||||
public async Task Login_AfterFiveWrongAttempts_Returns429WithBlockedDetail()
|
public async Task Login_AfterFiveWrongAttempts_Returns429WithBlockedDetail()
|
||||||
{
|
{
|
||||||
await OperatorAuthHttpHost.RunAsync(
|
await OperatorAuthHttpHost.RunAsync(
|
||||||
new FakeOperatorAuthStore(),
|
new TestOperatorAuthStore(),
|
||||||
NewUserStore(),
|
NewUserStore(),
|
||||||
async (baseAddress, _, users, _, _, _, _) =>
|
async (baseAddress, _, users, _, _, _, _) =>
|
||||||
{
|
{
|
||||||
@@ -66,7 +66,7 @@ public sealed class LoginAttemptEndpointHttpTests
|
|||||||
public async Task Login_SuccessResetsCounter_FurtherFailuresNotBlocked()
|
public async Task Login_SuccessResetsCounter_FurtherFailuresNotBlocked()
|
||||||
{
|
{
|
||||||
await OperatorAuthHttpHost.RunAsync(
|
await OperatorAuthHttpHost.RunAsync(
|
||||||
new FakeOperatorAuthStore(),
|
new TestOperatorAuthStore(),
|
||||||
NewUserStore(),
|
NewUserStore(),
|
||||||
async (baseAddress, _, users, _, _, _, _) =>
|
async (baseAddress, _, users, _, _, _, _) =>
|
||||||
{
|
{
|
||||||
@@ -108,7 +108,7 @@ public sealed class LoginAttemptEndpointHttpTests
|
|||||||
using var capture = new SecurityEventCapture();
|
using var capture = new SecurityEventCapture();
|
||||||
|
|
||||||
await OperatorAuthHttpHost.RunAsync(
|
await OperatorAuthHttpHost.RunAsync(
|
||||||
new FakeOperatorAuthStore(),
|
new TestOperatorAuthStore(),
|
||||||
NewUserStore(),
|
NewUserStore(),
|
||||||
async (baseAddress, _, _, _, _, _, _) =>
|
async (baseAddress, _, _, _, _, _, _) =>
|
||||||
{
|
{
|
||||||
@@ -136,10 +136,10 @@ public sealed class LoginAttemptEndpointHttpTests
|
|||||||
new() { Enabled = true };
|
new() { Enabled = true };
|
||||||
|
|
||||||
// Фейк-хранилище пользователей с активным пользователем Login/Password.
|
// Фейк-хранилище пользователей с активным пользователем Login/Password.
|
||||||
private static FakeAuthStore NewUserStore()
|
private static TestAuthStore NewUserStore()
|
||||||
{
|
{
|
||||||
var passwordHasher = TestHashers.New();
|
var passwordHasher = TestHashers.New();
|
||||||
var store = new FakeAuthStore();
|
var store = new TestAuthStore();
|
||||||
store.AddUser(new StoredUserDto(
|
store.AddUser(new StoredUserDto(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
Login,
|
Login,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Deal.Modules.Kanban.Application.Models;
|
|||||||
using Deal.Modules.Pipeline.Application.Models;
|
using Deal.Modules.Pipeline.Application.Models;
|
||||||
using Deal.Modules.Pipeline.Application.Parse;
|
using Deal.Modules.Pipeline.Application.Parse;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
|
using Deal.Tests.Unit.Support;
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Support;
|
namespace Deal.Tests.Unit.Support;
|
||||||
@@ -428,10 +429,10 @@ public sealed class MessageParseCoreTests
|
|||||||
public async Task ParseAsync_ReadsMarkerOverridesFromSettings()
|
public async Task ParseAsync_ReadsMarkerOverridesFromSettings()
|
||||||
{
|
{
|
||||||
// Кастомные hireMarkers из хранилища перекрывают дефолты: «вакансия» больше не маркер, свой — маркер.
|
// Кастомные hireMarkers из хранилища перекрывают дефолты: «вакансия» больше не маркер, свой — маркер.
|
||||||
var store = new FakeSettingsStore();
|
var store = new TestSettingsStore();
|
||||||
store.Preload(SettingsKeys.HireMarkers, JsonSerializer.Serialize(new[] { "нанимаем джунов" }));
|
store.Preload(SettingsKeys.HireMarkers, JsonSerializer.Serialize(new[] { "нанимаем джунов" }));
|
||||||
|
|
||||||
LocalFieldsParser parser = new(store);
|
LocalFieldsParser parser = new(store.Store);
|
||||||
LocalParsedFields custom = await parser.ParseAsync("Срочно нанимаем джунов на проект", default);
|
LocalParsedFields custom = await parser.ParseAsync("Срочно нанимаем джунов на проект", default);
|
||||||
LocalParsedFields withoutMarker = await parser.ParseAsync("Вакансия Java-разработчика на проект", default);
|
LocalParsedFields withoutMarker = await parser.ParseAsync("Вакансия Java-разработчика на проект", default);
|
||||||
|
|
||||||
@@ -442,7 +443,7 @@ public sealed class MessageParseCoreTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ParseAsync_NoOverrides_UsesDefaults()
|
public async Task ParseAsync_NoOverrides_UsesDefaults()
|
||||||
{
|
{
|
||||||
LocalFieldsParser parser = new(new FakeSettingsStore());
|
LocalFieldsParser parser = new(new TestSettingsStore().Store);
|
||||||
|
|
||||||
LocalParsedFields result = await parser.ParseAsync("Вакансия в команду на проект", default);
|
LocalParsedFields result = await parser.ParseAsync("Вакансия в команду на проект", default);
|
||||||
|
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Overview_ReturnsTenantsTokensEventsAndLoginCounters()
|
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.TenantLoginOk, AuditActorTypes.Tenant, ActiveTenant);
|
||||||
await SeedAuditAsync(auditStore, AuditEvents.TenantLogout, AuditActorTypes.Tenant, ActiveTenant);
|
await SeedAuditAsync(auditStore, AuditEvents.TenantLogout, AuditActorTypes.Tenant, ActiveTenant);
|
||||||
await SeedAuditAsync(auditStore, AuditEvents.TenantLoginFailed, AuditActorTypes.Tenant, ActiveTenant);
|
await SeedAuditAsync(auditStore, AuditEvents.TenantLoginFailed, AuditActorTypes.Tenant, ActiveTenant);
|
||||||
@@ -83,10 +83,10 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Suspicious_ReturnsFindingsForFailedLoginBurst()
|
public async Task Suspicious_ReturnsFindingsForFailedLoginBurst()
|
||||||
{
|
{
|
||||||
var auditStore = new FakeAuditLogStore();
|
var auditStore = new TestAuditLogStore();
|
||||||
for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerIpThreshold; i++)
|
for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerIpThreshold; i++)
|
||||||
{
|
{
|
||||||
await auditStore.AppendAsync(
|
await auditStore.Store.AppendAsync(
|
||||||
new AuditRecordDto(
|
new AuditRecordDto(
|
||||||
AuditEvents.TenantLoginFailed,
|
AuditEvents.TenantLoginFailed,
|
||||||
AuditActorTypes.Tenant,
|
AuditActorTypes.Tenant,
|
||||||
@@ -179,7 +179,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Activity_FiltersByActorIdAndPaginates()
|
public async Task Activity_FiltersByActorIdAndPaginates()
|
||||||
{
|
{
|
||||||
var auditStore = new FakeAuditLogStore();
|
var auditStore = new TestAuditLogStore();
|
||||||
Guid actor = Guid.NewGuid();
|
Guid actor = Guid.NewGuid();
|
||||||
await SeedAuditAsync(auditStore, AuditEvents.CardCreated, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 30);
|
await SeedAuditAsync(auditStore, AuditEvents.CardCreated, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 30);
|
||||||
await SeedAuditAsync(auditStore, AuditEvents.CardMoved, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 20);
|
await SeedAuditAsync(auditStore, AuditEvents.CardMoved, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 20);
|
||||||
@@ -222,7 +222,7 @@ public sealed class OperatorAnalyticsEndpointsHttpTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Audit_FiltersByActorIdAndOffset()
|
public async Task Audit_FiltersByActorIdAndOffset()
|
||||||
{
|
{
|
||||||
var auditStore = new FakeAuditLogStore();
|
var auditStore = new TestAuditLogStore();
|
||||||
Guid actor = Guid.NewGuid();
|
Guid actor = Guid.NewGuid();
|
||||||
await SeedAuditAsync(auditStore, AuditEvents.CardCreated, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 30);
|
await SeedAuditAsync(auditStore, AuditEvents.CardCreated, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 30);
|
||||||
await SeedAuditAsync(auditStore, AuditEvents.CardMoved, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 20);
|
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(ActiveTenant, "active-tenant", TenantStatuses.Active, DateTimeOffset.UtcNow.AddDays(-2)),
|
||||||
new TenantRecordDto(SuspendedTenant, "suspended-tenant", TenantStatuses.Suspended, DateTimeOffset.UtcNow.AddDays(-1)));
|
new TenantRecordDto(SuspendedTenant, "suspended-tenant", TenantStatuses.Suspended, DateTimeOffset.UtcNow.AddDays(-1)));
|
||||||
|
|
||||||
private static FakeOperatorAuthStore NewOperatorStore()
|
private static TestOperatorAuthStore NewOperatorStore()
|
||||||
{
|
{
|
||||||
var passwordHasher = TestHashers.New();
|
var passwordHasher = TestHashers.New();
|
||||||
var store = new FakeOperatorAuthStore();
|
var store = new TestOperatorAuthStore();
|
||||||
store.AddOperator(new StoredOperatorDto(
|
store.AddOperator(new StoredOperatorDto(
|
||||||
Guid.NewGuid(), OperatorLogin, "active", passwordHasher.Hash(OperatorPassword)));
|
Guid.NewGuid(), OperatorLogin, "active", passwordHasher.Hash(OperatorPassword)));
|
||||||
return store;
|
return store;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static FakeAuthStore NewUserStore()
|
private static TestAuthStore NewUserStore()
|
||||||
{
|
{
|
||||||
var passwordHasher = TestHashers.New();
|
var passwordHasher = TestHashers.New();
|
||||||
var store = new FakeAuthStore();
|
var store = new TestAuthStore();
|
||||||
store.AddUser(new StoredUserDto(
|
store.AddUser(new StoredUserDto(
|
||||||
Guid.NewGuid(), UserLogin, ActiveTenant, "active", passwordHasher.Hash(UserPassword)));
|
Guid.NewGuid(), UserLogin, ActiveTenant, "active", passwordHasher.Hash(UserPassword)));
|
||||||
return store;
|
return store;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Task SeedAuditAsync(
|
private static Task SeedAuditAsync(
|
||||||
FakeAuditLogStore store,
|
TestAuditLogStore store,
|
||||||
string eventType,
|
string eventType,
|
||||||
string actorType,
|
string actorType,
|
||||||
Guid? tenantId,
|
Guid? tenantId,
|
||||||
Guid? actorId = null,
|
Guid? actorId = null,
|
||||||
int minutesAgo = 1) =>
|
int minutesAgo = 1) =>
|
||||||
store.AppendAsync(
|
store.Store.AppendAsync(
|
||||||
new AuditRecordDto(
|
new AuditRecordDto(
|
||||||
eventType,
|
eventType,
|
||||||
actorType,
|
actorType,
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public sealed class OperatorAuditEndpointsHttpTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task OperatorLoginSuccess_WritesOperatorLoginOkAuditRecord()
|
public async Task OperatorLoginSuccess_WritesOperatorLoginOkAuditRecord()
|
||||||
{
|
{
|
||||||
var auditStore = new FakeAuditLogStore();
|
var auditStore = new TestAuditLogStore();
|
||||||
|
|
||||||
await OperatorAuthHttpHost.RunAsync(
|
await OperatorAuthHttpHost.RunAsync(
|
||||||
NewOperatorStore(),
|
NewOperatorStore(),
|
||||||
@@ -65,7 +65,7 @@ public sealed class OperatorAuditEndpointsHttpTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task OperatorLoginFailure_WritesOperatorLoginFailedAuditRecord()
|
public async Task OperatorLoginFailure_WritesOperatorLoginFailedAuditRecord()
|
||||||
{
|
{
|
||||||
var auditStore = new FakeAuditLogStore();
|
var auditStore = new TestAuditLogStore();
|
||||||
|
|
||||||
await OperatorAuthHttpHost.RunAsync(
|
await OperatorAuthHttpHost.RunAsync(
|
||||||
NewOperatorStore(),
|
NewOperatorStore(),
|
||||||
@@ -91,7 +91,7 @@ public sealed class OperatorAuditEndpointsHttpTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task TenantLoginSuccessAndFailure_WriteTenantAuditRecords()
|
public async Task TenantLoginSuccessAndFailure_WriteTenantAuditRecords()
|
||||||
{
|
{
|
||||||
var auditStore = new FakeAuditLogStore();
|
var auditStore = new TestAuditLogStore();
|
||||||
|
|
||||||
await OperatorAuthHttpHost.RunAsync(
|
await OperatorAuthHttpHost.RunAsync(
|
||||||
NewOperatorStore(),
|
NewOperatorStore(),
|
||||||
@@ -137,9 +137,9 @@ public sealed class OperatorAuditEndpointsHttpTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Audit_ReturnsItemsNewestFirstWithTotal()
|
public async Task Audit_ReturnsItemsNewestFirstWithTotal()
|
||||||
{
|
{
|
||||||
var auditStore = new FakeAuditLogStore();
|
var auditStore = new TestAuditLogStore();
|
||||||
// Историческая запись со старым At — гарантированно последняя в выборке (проверка At DESC).
|
// Историческая запись со старым At — гарантированно последняя в выборке (проверка At DESC).
|
||||||
await auditStore.AppendAsync(new AuditRecordDto(
|
await auditStore.Store.AppendAsync(new AuditRecordDto(
|
||||||
AuditEvents.TenantCreated,
|
AuditEvents.TenantCreated,
|
||||||
AuditActorTypes.Operator,
|
AuditActorTypes.Operator,
|
||||||
ActorId: Guid.NewGuid(),
|
ActorId: Guid.NewGuid(),
|
||||||
@@ -177,7 +177,7 @@ public sealed class OperatorAuditEndpointsHttpTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Audit_FiltersByEventTypeAndActorType()
|
public async Task Audit_FiltersByEventTypeAndActorType()
|
||||||
{
|
{
|
||||||
var auditStore = new FakeAuditLogStore();
|
var auditStore = new TestAuditLogStore();
|
||||||
|
|
||||||
await OperatorAuthHttpHost.RunAsync(
|
await OperatorAuthHttpHost.RunAsync(
|
||||||
NewOperatorStore(),
|
NewOperatorStore(),
|
||||||
@@ -218,7 +218,7 @@ public sealed class OperatorAuditEndpointsHttpTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task TenantLogout_WritesTenantLogoutAuditRecord()
|
public async Task TenantLogout_WritesTenantLogoutAuditRecord()
|
||||||
{
|
{
|
||||||
var auditStore = new FakeAuditLogStore();
|
var auditStore = new TestAuditLogStore();
|
||||||
|
|
||||||
await OperatorAuthHttpHost.RunAsync(
|
await OperatorAuthHttpHost.RunAsync(
|
||||||
NewOperatorStore(),
|
NewOperatorStore(),
|
||||||
@@ -251,7 +251,7 @@ public sealed class OperatorAuditEndpointsHttpTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task OperatorLogout_WritesOperatorLogoutAuditRecord()
|
public async Task OperatorLogout_WritesOperatorLogoutAuditRecord()
|
||||||
{
|
{
|
||||||
var auditStore = new FakeAuditLogStore();
|
var auditStore = new TestAuditLogStore();
|
||||||
|
|
||||||
await OperatorAuthHttpHost.RunAsync(
|
await OperatorAuthHttpHost.RunAsync(
|
||||||
NewOperatorStore(),
|
NewOperatorStore(),
|
||||||
@@ -279,10 +279,10 @@ public sealed class OperatorAuditEndpointsHttpTests
|
|||||||
// ─── Хелперы ─────────────────────────────────────────────────────────
|
// ─── Хелперы ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Фейк-хранилище оператора с активным operator/operator.
|
// Фейк-хранилище оператора с активным operator/operator.
|
||||||
private static FakeOperatorAuthStore NewOperatorStore()
|
private static TestOperatorAuthStore NewOperatorStore()
|
||||||
{
|
{
|
||||||
var passwordHasher = TestHashers.New();
|
var passwordHasher = TestHashers.New();
|
||||||
var store = new FakeOperatorAuthStore();
|
var store = new TestOperatorAuthStore();
|
||||||
store.AddOperator(new StoredOperatorDto(
|
store.AddOperator(new StoredOperatorDto(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
OperatorLogin,
|
OperatorLogin,
|
||||||
@@ -292,10 +292,10 @@ public sealed class OperatorAuditEndpointsHttpTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Фейк-хранилище пользователей с admin/admin в дефолтном тенанте.
|
// Фейк-хранилище пользователей с admin/admin в дефолтном тенанте.
|
||||||
private static FakeAuthStore NewUserStore()
|
private static TestAuthStore NewUserStore()
|
||||||
{
|
{
|
||||||
var passwordHasher = TestHashers.New();
|
var passwordHasher = TestHashers.New();
|
||||||
var store = new FakeAuthStore();
|
var store = new TestAuthStore();
|
||||||
store.AddUser(new StoredUserDto(
|
store.AddUser(new StoredUserDto(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
UserLogin,
|
UserLogin,
|
||||||
|
|||||||
@@ -131,7 +131,7 @@ public sealed class OperatorAuthEndpointsHttpTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task OperatorWithNonActiveStatus_LoginSucceeds_ButMeReturns401()
|
public async Task OperatorWithNonActiveStatus_LoginSucceeds_ButMeReturns401()
|
||||||
{
|
{
|
||||||
FakeOperatorAuthStore operatorStore = NewOperatorStore(active: false);
|
TestOperatorAuthStore operatorStore = NewOperatorStore(active: false);
|
||||||
|
|
||||||
await OperatorAuthHttpHost.RunAsync(
|
await OperatorAuthHttpHost.RunAsync(
|
||||||
operatorStore,
|
operatorStore,
|
||||||
@@ -189,10 +189,10 @@ public sealed class OperatorAuthEndpointsHttpTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Фейк-хранилище оператора с активным (или нет) оператором operator/operator.
|
// Фейк-хранилище оператора с активным (или нет) оператором operator/operator.
|
||||||
private static FakeOperatorAuthStore NewOperatorStore(bool active = true)
|
private static TestOperatorAuthStore NewOperatorStore(bool active = true)
|
||||||
{
|
{
|
||||||
var passwordHasher = TestHashers.New();
|
var passwordHasher = TestHashers.New();
|
||||||
var store = new FakeOperatorAuthStore();
|
var store = new TestOperatorAuthStore();
|
||||||
store.AddOperator(new StoredOperatorDto(
|
store.AddOperator(new StoredOperatorDto(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
OperatorLogin,
|
OperatorLogin,
|
||||||
@@ -202,10 +202,10 @@ public sealed class OperatorAuthEndpointsHttpTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Фейк-хранилище пользователей с admin/admin в дефолтном тенанте.
|
// Фейк-хранилище пользователей с admin/admin в дефолтном тенанте.
|
||||||
private static FakeAuthStore NewUserStore()
|
private static TestAuthStore NewUserStore()
|
||||||
{
|
{
|
||||||
var passwordHasher = TestHashers.New();
|
var passwordHasher = TestHashers.New();
|
||||||
var store = new FakeAuthStore();
|
var store = new TestAuthStore();
|
||||||
store.AddUser(new StoredUserDto(
|
store.AddUser(new StoredUserDto(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
UserLogin,
|
UserLogin,
|
||||||
|
|||||||
@@ -33,17 +33,17 @@ internal static class OperatorAuthHttpHost
|
|||||||
"Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password;Timeout=3";
|
"Host=localhost;Port=5433;Database=deal;Username=deal;Password=deal_dev_password;Timeout=3";
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Прогоняет сценарий против поднятого хоста с фейк-хранилищами оператора и пользователей.
|
/// Прогоняет сценарий против поднятого хоста с подставками хранилищ оператора и пользователей.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="operatorStore">Фейк-хранилище оператора (IOperatorAuthStore).</param>
|
/// <param name="operatorStore">Подставка хранилища оператора (IOperatorAuthStore).</param>
|
||||||
/// <param name="userStore">Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
|
/// <param name="userStore">Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
|
||||||
/// <param name="scenario">Сценарий: получает base-адрес и хранилища для проверок/досидирования.</param>
|
/// <param name="scenario">Сценарий: получает base-адрес и хранилища для проверок/досидирования.</param>
|
||||||
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
|
/// <param name="auditStore">Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
|
||||||
public static async Task RunAsync(
|
public static async Task RunAsync(
|
||||||
FakeOperatorAuthStore operatorStore,
|
TestOperatorAuthStore operatorStore,
|
||||||
FakeAuthStore userStore,
|
TestAuthStore userStore,
|
||||||
Func<string, FakeOperatorAuthStore, FakeAuthStore, Task> scenario,
|
Func<string, TestOperatorAuthStore, TestAuthStore, Task> scenario,
|
||||||
FakeAuditLogStore? auditStore = null) =>
|
TestAuditLogStore? auditStore = null) =>
|
||||||
await RunCoreAsync(
|
await RunCoreAsync(
|
||||||
operatorStore,
|
operatorStore,
|
||||||
userStore,
|
userStore,
|
||||||
@@ -56,16 +56,16 @@ internal static class OperatorAuthHttpHost
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Прогоняет сценарий
|
/// Прогоняет сценарий
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="operatorStore">Фейк-хранилище оператора (IOperatorAuthStore).</param>
|
/// <param name="operatorStore">Подставка хранилища оператора (IOperatorAuthStore).</param>
|
||||||
/// <param name="userStore">Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
|
/// <param name="userStore">Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
|
||||||
/// <param name="scenario">Сценарий: получает base-адрес и хранилища (без реестра тенантов).</param>
|
/// <param name="scenario">Сценарий: получает base-адрес и хранилища (без реестра тенантов).</param>
|
||||||
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
|
/// <param name="auditStore">Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
|
||||||
/// <param name="inviteStore">Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри.</param>
|
/// <param name="inviteStore">Подставка хранилища приглашений (IInviteStore); null — создаётся пустое внутри.</param>
|
||||||
public static async Task RunAsync(
|
public static async Task RunAsync(
|
||||||
FakeOperatorAuthStore operatorStore,
|
TestOperatorAuthStore operatorStore,
|
||||||
FakeAuthStore userStore,
|
TestAuthStore userStore,
|
||||||
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestInviteStore, FakeAuditLogStore, Task> scenario,
|
Func<string, TestOperatorAuthStore, TestAuthStore, TestInviteStore, TestAuditLogStore, Task> scenario,
|
||||||
FakeAuditLogStore? auditStore = null,
|
TestAuditLogStore? auditStore = null,
|
||||||
TestInviteStore? inviteStore = null) =>
|
TestInviteStore? inviteStore = null) =>
|
||||||
await RunCoreAsync(
|
await RunCoreAsync(
|
||||||
operatorStore,
|
operatorStore,
|
||||||
@@ -79,17 +79,17 @@ internal static class OperatorAuthHttpHost
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Прогоняет сценарий
|
/// Прогоняет сценарий
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="operatorStore">Фейк-хранилище оператора (IOperatorAuthStore).</param>
|
/// <param name="operatorStore">Подставка хранилища оператора (IOperatorAuthStore).</param>
|
||||||
/// <param name="userStore">Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
|
/// <param name="userStore">Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
|
||||||
/// <param name="scenario">Сценарий: получает base-адрес и все хранилища, кроме лимитов.</param>
|
/// <param name="scenario">Сценарий: получает base-адрес и все хранилища, кроме лимитов.</param>
|
||||||
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
|
/// <param name="auditStore">Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
|
||||||
/// <param name="inviteStore">Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри.</param>
|
/// <param name="inviteStore">Подставка хранилища приглашений (IInviteStore); null — создаётся пустое внутри.</param>
|
||||||
/// <param name="tenantStore">Фейк-хранилище реестра тенантов (ITenantRepository); null — создаётся пустое внутри.</param>
|
/// <param name="tenantStore">Подставка хранилища реестра тенантов (ITenantRepository); null — создаётся пустое внутри.</param>
|
||||||
public static async Task RunAsync(
|
public static async Task RunAsync(
|
||||||
FakeOperatorAuthStore operatorStore,
|
TestOperatorAuthStore operatorStore,
|
||||||
FakeAuthStore userStore,
|
TestAuthStore userStore,
|
||||||
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, Task> scenario,
|
Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, Task> scenario,
|
||||||
FakeAuditLogStore? auditStore = null,
|
TestAuditLogStore? auditStore = null,
|
||||||
TestInviteStore? inviteStore = null,
|
TestInviteStore? inviteStore = null,
|
||||||
TestTenantStore? tenantStore = null) =>
|
TestTenantStore? tenantStore = null) =>
|
||||||
await RunCoreAsync(
|
await RunCoreAsync(
|
||||||
@@ -104,22 +104,22 @@ internal static class OperatorAuthHttpHost
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Прогоняет сценарий
|
/// Прогоняет сценарий
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="operatorStore">Фейк-хранилище оператора (IOperatorAuthStore).</param>
|
/// <param name="operatorStore">Подставка хранилища оператора (IOperatorAuthStore).</param>
|
||||||
/// <param name="userStore">Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
|
/// <param name="userStore">Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
|
||||||
/// <param name="scenario">Сценарий: получает base-адрес и все хранилища, включая лимиты.</param>
|
/// <param name="scenario">Сценарий: получает base-адрес и все хранилища, включая лимиты.</param>
|
||||||
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
|
/// <param name="auditStore">Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
|
||||||
/// <param name="inviteStore">Фейк-хранилище приглашений (IInviteStore); null — создаётся пустое внутри.</param>
|
/// <param name="inviteStore">Подставка хранилища приглашений (IInviteStore); null — создаётся пустое внутри.</param>
|
||||||
/// <param name="tenantStore">Фейк-хранилище реестра тенантов (ITenantRepository); null — создаётся пустое внутри.</param>
|
/// <param name="tenantStore">Подставка хранилища реестра тенантов (ITenantRepository); null — создаётся пустое внутри.</param>
|
||||||
/// <param name="limitStore">Фейк-хранилище лимитов (ITenantLimitStore); null — создаётся пустое внутри.</param>
|
/// <param name="limitStore">Подставка хранилища лимитов (ITenantLimitStore); null — создаётся пустое внутри.</param>
|
||||||
/// <param name="rateLimitOptions">Опции rate limiting: null — выключенные по умолчанию (LoginAttemptGuard no-op, существующие сценарии не режутся); Enabled-опции — сценарии защиты входа.</param>
|
/// <param name="rateLimitOptions">Опции rate limiting: null — выключенные по умолчанию (LoginAttemptGuard no-op, существующие сценарии не режутся); Enabled-опции — сценарии защиты входа.</param>
|
||||||
public static async Task RunAsync(
|
public static async Task RunAsync(
|
||||||
FakeOperatorAuthStore operatorStore,
|
TestOperatorAuthStore operatorStore,
|
||||||
FakeAuthStore userStore,
|
TestAuthStore userStore,
|
||||||
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario,
|
Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, Task> scenario,
|
||||||
FakeAuditLogStore? auditStore = null,
|
TestAuditLogStore? auditStore = null,
|
||||||
TestInviteStore? inviteStore = null,
|
TestInviteStore? inviteStore = null,
|
||||||
TestTenantStore? tenantStore = null,
|
TestTenantStore? tenantStore = null,
|
||||||
FakeTenantLimitStore? limitStore = null,
|
TestTenantLimitStore? limitStore = null,
|
||||||
RateLimitOptions? rateLimitOptions = null,
|
RateLimitOptions? rateLimitOptions = null,
|
||||||
TestTokenUsageEventStore? tokenUsageStore = null) =>
|
TestTokenUsageEventStore? tokenUsageStore = null) =>
|
||||||
await RunCoreAsync(
|
await RunCoreAsync(
|
||||||
@@ -136,16 +136,16 @@ internal static class OperatorAuthHttpHost
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Прогоняет сценарий с глобальными настройками
|
/// Прогоняет сценарий с глобальными настройками
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="operatorStore">Фейк-хранилище оператора (IOperatorAuthStore).</param>
|
/// <param name="operatorStore">Подставка хранилища оператора (IOperatorAuthStore).</param>
|
||||||
/// <param name="userStore">Фейк-хранилище пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
|
/// <param name="userStore">Подставка хранилища пользователей (IAuthStore) для тенантных ручек /api/auth.</param>
|
||||||
/// <param name="scenario">Сценарий: base-адрес, хранилища оператора/пользователей/глобальных настроек и аудита.</param>
|
/// <param name="scenario">Сценарий: base-адрес, хранилища оператора/пользователей/глобальных настроек и аудита.</param>
|
||||||
/// <param name="auditStore">Фейк-хранилище аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
|
/// <param name="auditStore">Подставка хранилища аудита (IAuditLogStore); null — создаётся пустое внутри.</param>
|
||||||
/// <param name="globalSettingsStore">Фейк глобального KV-хранилища (IGlobalSettingsStore); null — создаётся пустое.</param>
|
/// <param name="globalSettingsStore">Подставка глобального KV-хранилища (IGlobalSettingsStore); null — создаётся пустое.</param>
|
||||||
public static async Task RunWithGlobalSettingsAsync(
|
public static async Task RunWithGlobalSettingsAsync(
|
||||||
FakeOperatorAuthStore operatorStore,
|
TestOperatorAuthStore operatorStore,
|
||||||
FakeAuthStore userStore,
|
TestAuthStore userStore,
|
||||||
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestGlobalSettingsStore, FakeAuditLogStore, Task> scenario,
|
Func<string, TestOperatorAuthStore, TestAuthStore, TestGlobalSettingsStore, TestAuditLogStore, Task> scenario,
|
||||||
FakeAuditLogStore? auditStore = null,
|
TestAuditLogStore? auditStore = null,
|
||||||
TestGlobalSettingsStore? globalSettingsStore = null) =>
|
TestGlobalSettingsStore? globalSettingsStore = null) =>
|
||||||
await RunCoreAsync(
|
await RunCoreAsync(
|
||||||
operatorStore,
|
operatorStore,
|
||||||
@@ -159,21 +159,21 @@ internal static class OperatorAuthHttpHost
|
|||||||
|
|
||||||
// Поднимает in-process Kestrel-хост и прогоняет сценарий (см. публичные перегрузки).
|
// Поднимает in-process Kestrel-хост и прогоняет сценарий (см. публичные перегрузки).
|
||||||
private static async Task RunCoreAsync(
|
private static async Task RunCoreAsync(
|
||||||
FakeOperatorAuthStore operatorStore,
|
TestOperatorAuthStore operatorStore,
|
||||||
FakeAuthStore userStore,
|
TestAuthStore userStore,
|
||||||
FakeAuditLogStore? auditStore,
|
TestAuditLogStore? auditStore,
|
||||||
TestInviteStore? inviteStore,
|
TestInviteStore? inviteStore,
|
||||||
TestTenantStore? tenantStore,
|
TestTenantStore? tenantStore,
|
||||||
FakeTenantLimitStore? limitStore,
|
TestTenantLimitStore? limitStore,
|
||||||
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, TestGlobalSettingsStore, Task> scenario,
|
Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, TestGlobalSettingsStore, Task> scenario,
|
||||||
RateLimitOptions? rateLimitOptions = null,
|
RateLimitOptions? rateLimitOptions = null,
|
||||||
TestTokenUsageEventStore? tokenUsageStore = null,
|
TestTokenUsageEventStore? tokenUsageStore = null,
|
||||||
TestGlobalSettingsStore? globalSettingsStore = null)
|
TestGlobalSettingsStore? globalSettingsStore = null)
|
||||||
{
|
{
|
||||||
FakeAuditLogStore effectiveAuditStore = auditStore ?? new FakeAuditLogStore();
|
TestAuditLogStore effectiveAuditStore = auditStore ?? new TestAuditLogStore();
|
||||||
TestInviteStore effectiveInviteStore = inviteStore ?? new TestInviteStore();
|
TestInviteStore effectiveInviteStore = inviteStore ?? new TestInviteStore();
|
||||||
TestTenantStore effectiveTenantStore = tenantStore ?? new TestTenantStore();
|
TestTenantStore effectiveTenantStore = tenantStore ?? new TestTenantStore();
|
||||||
FakeTenantLimitStore effectiveLimitStore = limitStore ?? new FakeTenantLimitStore();
|
TestTenantLimitStore effectiveLimitStore = limitStore ?? new TestTenantLimitStore();
|
||||||
TestTokenUsageEventStore effectiveTokenUsageStore = tokenUsageStore ?? new TestTokenUsageEventStore();
|
TestTokenUsageEventStore effectiveTokenUsageStore = tokenUsageStore ?? new TestTokenUsageEventStore();
|
||||||
TestGlobalSettingsStore effectiveGlobalSettingsStore = globalSettingsStore ?? new TestGlobalSettingsStore();
|
TestGlobalSettingsStore effectiveGlobalSettingsStore = globalSettingsStore ?? new TestGlobalSettingsStore();
|
||||||
RateLimitOptions effectiveRateLimitOptions = rateLimitOptions ?? new RateLimitOptions();
|
RateLimitOptions effectiveRateLimitOptions = rateLimitOptions ?? new RateLimitOptions();
|
||||||
@@ -186,12 +186,12 @@ internal static class OperatorAuthHttpHost
|
|||||||
// последняя регистрация (зеркало AddTenantsModule в тестах DiscoveryWorkerSchedulerTests).
|
// последняя регистрация (зеркало AddTenantsModule в тестах DiscoveryWorkerSchedulerTests).
|
||||||
builder.Services.AddSingleton(TestHashers.New());
|
builder.Services.AddSingleton(TestHashers.New());
|
||||||
builder.Services.AddSingleton<ITenantContext, TenantContext>();
|
builder.Services.AddSingleton<ITenantContext, TenantContext>();
|
||||||
builder.Services.AddSingleton<IAuthStore>(userStore);
|
builder.Services.AddSingleton<IAuthStore>(userStore.Store);
|
||||||
builder.Services.AddSingleton<IOperatorAuthStore>(operatorStore);
|
builder.Services.AddSingleton<IOperatorAuthStore>(operatorStore.Store);
|
||||||
builder.Services.AddSingleton<IAuditLogStore>(effectiveAuditStore);
|
builder.Services.AddSingleton<IAuditLogStore>(effectiveAuditStore.Store);
|
||||||
builder.Services.AddSingleton<IInviteStore>(effectiveInviteStore.Store);
|
builder.Services.AddSingleton<IInviteStore>(effectiveInviteStore.Store);
|
||||||
builder.Services.AddSingleton<ITenantRepository>(effectiveTenantStore.Repository);
|
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<IGlobalSettingsStore>(effectiveGlobalSettingsStore.Store);
|
||||||
builder.Services.AddSingleton<ISecretCipher>(TestCiphers.New());
|
builder.Services.AddSingleton<ISecretCipher>(TestCiphers.New());
|
||||||
// Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys).
|
// Сервис глобальных ключей Telegram (операторские ручки /api/operator/settings/telegram-keys).
|
||||||
|
|||||||
@@ -92,14 +92,14 @@ public sealed class OperatorHealthEndpointsHttpTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Прогоняет сценарий на хосте с активным оператором operator/operator (реестр/лимиты пустые).
|
// Прогоняет сценарий на хосте с активным оператором operator/operator (реестр/лимиты пустые).
|
||||||
private static Task RunAsync(Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, FakeTenantLimitStore, Task> scenario) =>
|
private static Task RunAsync(Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, TestTenantLimitStore, Task> scenario) =>
|
||||||
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario);
|
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new TestAuthStore(), scenario);
|
||||||
|
|
||||||
// Фейк-хранилище оператора с активным оператором operator/operator.
|
// Фейк-хранилище оператора с активным оператором operator/operator.
|
||||||
private static FakeOperatorAuthStore NewOperatorStore()
|
private static TestOperatorAuthStore NewOperatorStore()
|
||||||
{
|
{
|
||||||
var passwordHasher = TestHashers.New();
|
var passwordHasher = TestHashers.New();
|
||||||
var store = new FakeOperatorAuthStore();
|
var store = new TestOperatorAuthStore();
|
||||||
store.AddOperator(new StoredOperatorDto(
|
store.AddOperator(new StoredOperatorDto(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
OperatorLogin,
|
OperatorLogin,
|
||||||
|
|||||||
@@ -70,7 +70,7 @@ public sealed class OperatorInvitesEndpointsHttpTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task CreateThenListThenRevoke_FullOperatorFlow_WorksAndWritesAudit()
|
public async Task CreateThenListThenRevoke_FullOperatorFlow_WorksAndWritesAudit()
|
||||||
{
|
{
|
||||||
var auditStore = new FakeAuditLogStore();
|
var auditStore = new TestAuditLogStore();
|
||||||
|
|
||||||
await OperatorAuthHttpHost.RunAsync(
|
await OperatorAuthHttpHost.RunAsync(
|
||||||
NewOperatorStore(),
|
NewOperatorStore(),
|
||||||
@@ -262,10 +262,10 @@ public sealed class OperatorInvitesEndpointsHttpTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Фейк-хранилище оператора с активным оператором operator/operator.
|
// Фейк-хранилище оператора с активным оператором operator/operator.
|
||||||
private static FakeOperatorAuthStore NewOperatorStore()
|
private static TestOperatorAuthStore NewOperatorStore()
|
||||||
{
|
{
|
||||||
var passwordHasher = TestHashers.New();
|
var passwordHasher = TestHashers.New();
|
||||||
var store = new FakeOperatorAuthStore();
|
var store = new TestOperatorAuthStore();
|
||||||
store.AddOperator(new StoredOperatorDto(
|
store.AddOperator(new StoredOperatorDto(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
OperatorLogin,
|
OperatorLogin,
|
||||||
@@ -275,7 +275,7 @@ public sealed class OperatorInvitesEndpointsHttpTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Пустое фейк-хранилище пользователей (тенантные ручки в этих сценариях не используются).
|
// Пустое фейк-хранилище пользователей (тенантные ручки в этих сценариях не используются).
|
||||||
private static FakeAuthStore NewUserStore() => new();
|
private static TestAuthStore NewUserStore() => new();
|
||||||
|
|
||||||
// HTTP-клиент с собственным CookieContainer (изоляция сценариев кук).
|
// HTTP-клиент с собственным CookieContainer (изоляция сценариев кук).
|
||||||
private static HttpClient CreateClient(string baseAddress) =>
|
private static HttpClient CreateClient(string baseAddress) =>
|
||||||
|
|||||||
@@ -120,7 +120,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PatchLimit_ResetsFlags_UpdatesBudgetAndWritesAudit()
|
public async Task PatchLimit_ResetsFlags_UpdatesBudgetAndWritesAudit()
|
||||||
{
|
{
|
||||||
var auditStore = new FakeAuditLogStore();
|
var auditStore = new TestAuditLogStore();
|
||||||
const long newBudget = 20_000_000;
|
const long newBudget = 20_000_000;
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
@@ -165,7 +165,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PatchLimit_OnlyPeriod_KeepsBudgetAndWritesAudit()
|
public async Task PatchLimit_OnlyPeriod_KeepsBudgetAndWritesAudit()
|
||||||
{
|
{
|
||||||
var auditStore = new FakeAuditLogStore();
|
var auditStore = new TestAuditLogStore();
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
async (baseAddress, operatorStore, _, _, _, _, _) =>
|
async (baseAddress, operatorStore, _, _, _, _, _) =>
|
||||||
@@ -195,7 +195,7 @@ public sealed class OperatorLimitsEndpointsHttpTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PatchLimit_WithSameValues_IsIdempotent_WithoutNewAudit()
|
public async Task PatchLimit_WithSameValues_IsIdempotent_WithoutNewAudit()
|
||||||
{
|
{
|
||||||
var auditStore = new FakeAuditLogStore();
|
var auditStore = new TestAuditLogStore();
|
||||||
const long budget = 25_000_000;
|
const long budget = 25_000_000;
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
@@ -284,19 +284,19 @@ public sealed class OperatorLimitsEndpointsHttpTests
|
|||||||
$"{baseAddress}/api/operator/tenants/{tenantId}/limit";
|
$"{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;
|
// Первый тенант: расход 80% месячного бюджета (8 000 000 / 10 000 000) + флаг Warned80;
|
||||||
// второй: небольшой бюджет без расхода. Дата начала периода — текущая (reset не срабатывает).
|
// второй: небольшой бюджет без расхода. Дата начала периода — текущая (reset не срабатывает).
|
||||||
var tenantStore = new TestTenantStore(
|
var tenantStore = new TestTenantStore(
|
||||||
new TenantRecordDto(FirstTenantId, FirstTenantName, TenantStatuses.Active, DateTimeOffset.UtcNow),
|
new TenantRecordDto(FirstTenantId, FirstTenantName, TenantStatuses.Active, DateTimeOffset.UtcNow),
|
||||||
new TenantRecordDto(SecondTenantId, SecondTenantName, 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(FirstTenantId, DefaultBudgetTokens, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 8_000_000, warned80: true);
|
||||||
limitStore.Preload(SecondTenantId, 100_000, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 0);
|
limitStore.Preload(SecondTenantId, 100_000, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 0);
|
||||||
return OperatorAuthHttpHost.RunAsync(
|
return OperatorAuthHttpHost.RunAsync(
|
||||||
NewOperatorStore(),
|
NewOperatorStore(),
|
||||||
new FakeAuthStore(),
|
new TestAuthStore(),
|
||||||
scenario,
|
scenario,
|
||||||
auditStore,
|
auditStore,
|
||||||
tenantStore: tenantStore,
|
tenantStore: tenantStore,
|
||||||
@@ -326,10 +326,10 @@ public sealed class OperatorLimitsEndpointsHttpTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Фейк-хранилище оператора с активным оператором operator/operator.
|
// Фейк-хранилище оператора с активным оператором operator/operator.
|
||||||
private static FakeOperatorAuthStore NewOperatorStore()
|
private static TestOperatorAuthStore NewOperatorStore()
|
||||||
{
|
{
|
||||||
var passwordHasher = TestHashers.New();
|
var passwordHasher = TestHashers.New();
|
||||||
var store = new FakeOperatorAuthStore();
|
var store = new TestOperatorAuthStore();
|
||||||
store.AddOperator(new StoredOperatorDto(
|
store.AddOperator(new StoredOperatorDto(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
OperatorLogin,
|
OperatorLogin,
|
||||||
|
|||||||
@@ -72,14 +72,14 @@ public sealed class OperatorMaintenanceEndpointsHttpTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Прогоняет сценарий на хосте с активным оператором operator/operator и заданным реестром тенантов.
|
// Прогоняет сценарий на хосте с активным оператором operator/operator и заданным реестром тенантов.
|
||||||
private static Task RunAsync(TestTenantStore tenantStore, Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, TestInviteStore, FakeAuditLogStore, Task> scenario) =>
|
private static Task RunAsync(TestTenantStore tenantStore, Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestInviteStore, TestAuditLogStore, Task> scenario) =>
|
||||||
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario, tenantStore: tenantStore);
|
OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new TestAuthStore(), scenario, tenantStore: tenantStore);
|
||||||
|
|
||||||
// Фейк-хранилище оператора с активным оператором operator/operator.
|
// Фейк-хранилище оператора с активным оператором operator/operator.
|
||||||
private static FakeOperatorAuthStore NewOperatorStore()
|
private static TestOperatorAuthStore NewOperatorStore()
|
||||||
{
|
{
|
||||||
var passwordHasher = TestHashers.New();
|
var passwordHasher = TestHashers.New();
|
||||||
var store = new FakeOperatorAuthStore();
|
var store = new TestOperatorAuthStore();
|
||||||
store.AddOperator(new StoredOperatorDto(
|
store.AddOperator(new StoredOperatorDto(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
OperatorLogin,
|
OperatorLogin,
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ public sealed class OperatorSettingsEndpointsHttpTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PutTelegramKeys_SavesEncryptedKeysMasksResponseAndWritesAudit()
|
public async Task PutTelegramKeys_SavesEncryptedKeysMasksResponseAndWritesAudit()
|
||||||
{
|
{
|
||||||
var auditStore = new FakeAuditLogStore();
|
var auditStore = new TestAuditLogStore();
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
async (baseAddress, operatorStore, _, globalSettings, audit) =>
|
async (baseAddress, operatorStore, _, globalSettings, audit) =>
|
||||||
@@ -257,10 +257,10 @@ public sealed class OperatorSettingsEndpointsHttpTests
|
|||||||
$"{baseAddress}/api/operator/settings/telegram-keys";
|
$"{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(
|
OperatorAuthHttpHost.RunWithGlobalSettingsAsync(
|
||||||
NewOperatorStore(),
|
NewOperatorStore(),
|
||||||
new FakeAuthStore(),
|
new TestAuthStore(),
|
||||||
scenario,
|
scenario,
|
||||||
auditStore);
|
auditStore);
|
||||||
|
|
||||||
@@ -273,10 +273,10 @@ public sealed class OperatorSettingsEndpointsHttpTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Фейк-хранилище оператора с активным оператором operator/operator.
|
// Фейк-хранилище оператора с активным оператором operator/operator.
|
||||||
private static FakeOperatorAuthStore NewOperatorStore()
|
private static TestOperatorAuthStore NewOperatorStore()
|
||||||
{
|
{
|
||||||
var passwordHasher = TestHashers.New();
|
var passwordHasher = TestHashers.New();
|
||||||
var store = new FakeOperatorAuthStore();
|
var store = new TestOperatorAuthStore();
|
||||||
store.AddOperator(new StoredOperatorDto(
|
store.AddOperator(new StoredOperatorDto(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
OperatorLogin,
|
OperatorLogin,
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Create_ReturnsCreatedTenant_AndWritesTenantCreatedAudit()
|
public async Task Create_ReturnsCreatedTenant_AndWritesTenantCreatedAudit()
|
||||||
{
|
{
|
||||||
var auditStore = new FakeAuditLogStore();
|
var auditStore = new TestAuditLogStore();
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
async (baseAddress, operatorStore, _, tenantStore, _) =>
|
async (baseAddress, operatorStore, _, tenantStore, _) =>
|
||||||
@@ -138,7 +138,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Create_WithOwnerEmail_ReturnsOneTimePasswordAndCreatesOwner()
|
public async Task Create_WithOwnerEmail_ReturnsOneTimePasswordAndCreatesOwner()
|
||||||
{
|
{
|
||||||
var auditStore = new FakeAuditLogStore();
|
var auditStore = new TestAuditLogStore();
|
||||||
const string ownerEmail = "owner@created.com";
|
const string ownerEmail = "owner@created.com";
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
@@ -237,7 +237,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Suspend_BlocksTenantLogin_ThenUnsuspend_RestoresLogin()
|
public async Task Suspend_BlocksTenantLogin_ThenUnsuspend_RestoresLogin()
|
||||||
{
|
{
|
||||||
var auditStore = new FakeAuditLogStore();
|
var auditStore = new TestAuditLogStore();
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
async (baseAddress, operatorStore, userStore, tenantStore, _) =>
|
async (baseAddress, operatorStore, userStore, tenantStore, _) =>
|
||||||
@@ -300,7 +300,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Suspend_AlreadySuspendedTenant_IsOkWithoutDuplicateAudit()
|
public async Task Suspend_AlreadySuspendedTenant_IsOkWithoutDuplicateAudit()
|
||||||
{
|
{
|
||||||
var auditStore = new FakeAuditLogStore();
|
var auditStore = new TestAuditLogStore();
|
||||||
var tenantStore = new TestTenantStore(
|
var tenantStore = new TestTenantStore(
|
||||||
new TenantRecordDto(TenantId, TenantName, TenantStatuses.Suspended, DateTimeOffset.UtcNow));
|
new TenantRecordDto(TenantId, TenantName, TenantStatuses.Suspended, DateTimeOffset.UtcNow));
|
||||||
|
|
||||||
@@ -342,7 +342,7 @@ public sealed class OperatorTenantsEndpointsHttpTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Impersonate_ReturnsSessionToken_WorksAsDealSession_AndLogoutWritesStoppedAudit()
|
public async Task Impersonate_ReturnsSessionToken_WorksAsDealSession_AndLogoutWritesStoppedAudit()
|
||||||
{
|
{
|
||||||
var auditStore = new FakeAuditLogStore();
|
var auditStore = new TestAuditLogStore();
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
async (baseAddress, operatorStore, userStore, _, _) =>
|
async (baseAddress, operatorStore, userStore, _, _) =>
|
||||||
@@ -486,8 +486,8 @@ public sealed class OperatorTenantsEndpointsHttpTests
|
|||||||
|
|
||||||
// Прогоняет сценарий на хосте с одним активным тенантом и одним пользователем в нём.
|
// Прогоняет сценарий на хосте с одним активным тенантом и одним пользователем в нём.
|
||||||
private static Task RunAsync(
|
private static Task RunAsync(
|
||||||
Func<string, FakeOperatorAuthStore, FakeAuthStore, TestTenantStore, FakeAuditLogStore, Task> scenario,
|
Func<string, TestOperatorAuthStore, TestAuthStore, TestTenantStore, TestAuditLogStore, Task> scenario,
|
||||||
FakeAuditLogStore? auditStore = null,
|
TestAuditLogStore? auditStore = null,
|
||||||
TestTenantStore? tenantStore = null)
|
TestTenantStore? tenantStore = null)
|
||||||
{
|
{
|
||||||
// По умолчанию в реестре — активный целевой тенант (пользователь в нём — NewUserStore).
|
// По умолчанию в реестре — активный целевой тенант (пользователь в нём — NewUserStore).
|
||||||
@@ -503,8 +503,8 @@ public sealed class OperatorTenantsEndpointsHttpTests
|
|||||||
|
|
||||||
// Проверяет запись tenant_status_changed с заданным статусом и актором-оператором.
|
// Проверяет запись tenant_status_changed с заданным статусом и актором-оператором.
|
||||||
private static void AssertStatusChangedAudit(
|
private static void AssertStatusChangedAudit(
|
||||||
FakeAuditLogStore auditStore,
|
TestAuditLogStore auditStore,
|
||||||
FakeOperatorAuthStore operatorStore,
|
TestOperatorAuthStore operatorStore,
|
||||||
string status)
|
string status)
|
||||||
{
|
{
|
||||||
// В ленте может быть несколько tenant_status_changed этого тенанта (suspend → unsuspend) — ищем по статусу.
|
// В ленте может быть несколько tenant_status_changed этого тенанта (suspend → unsuspend) — ищем по статусу.
|
||||||
@@ -569,10 +569,10 @@ public sealed class OperatorTenantsEndpointsHttpTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Фейк-хранилище оператора с активным оператором operator/operator.
|
// Фейк-хранилище оператора с активным оператором operator/operator.
|
||||||
private static FakeOperatorAuthStore NewOperatorStore()
|
private static TestOperatorAuthStore NewOperatorStore()
|
||||||
{
|
{
|
||||||
var passwordHasher = TestHashers.New();
|
var passwordHasher = TestHashers.New();
|
||||||
var store = new FakeOperatorAuthStore();
|
var store = new TestOperatorAuthStore();
|
||||||
store.AddOperator(new StoredOperatorDto(
|
store.AddOperator(new StoredOperatorDto(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
OperatorLogin,
|
OperatorLogin,
|
||||||
@@ -582,10 +582,10 @@ public sealed class OperatorTenantsEndpointsHttpTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Фейк-хранилище пользователей с одним пользователем в целевом тенанте.
|
// Фейк-хранилище пользователей с одним пользователем в целевом тенанте.
|
||||||
private static FakeAuthStore NewUserStore()
|
private static TestAuthStore NewUserStore()
|
||||||
{
|
{
|
||||||
var passwordHasher = TestHashers.New();
|
var passwordHasher = TestHashers.New();
|
||||||
var store = new FakeAuthStore();
|
var store = new TestAuthStore();
|
||||||
store.AddUser(new StoredUserDto(
|
store.AddUser(new StoredUserDto(
|
||||||
Guid.NewGuid(),
|
Guid.NewGuid(),
|
||||||
TenantUserLogin,
|
TenantUserLogin,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Deal.Api.Events;
|
|||||||
using Deal.Api.Services;
|
using Deal.Api.Services;
|
||||||
using Deal.Contracts.Integrations.Abstractions;
|
using Deal.Contracts.Integrations.Abstractions;
|
||||||
using Deal.Contracts.Integrations.Models;
|
using Deal.Contracts.Integrations.Models;
|
||||||
|
using Deal.Tests.Unit.Support;
|
||||||
using Deal.Infrastructure.Data;
|
using Deal.Infrastructure.Data;
|
||||||
using Deal.Modules.Kanban.Application.Abstractions;
|
using Deal.Modules.Kanban.Application.Abstractions;
|
||||||
using Deal.Modules.Kanban.Application.Models;
|
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.Abstractions;
|
||||||
using Deal.SharedKernel.Tenants.Models;
|
using Deal.SharedKernel.Tenants.Models;
|
||||||
using Deal.Tests.Unit.Contracts;
|
using Deal.Tests.Unit.Contracts;
|
||||||
using Deal.Tests.Unit.Modules.Cards;
|
|
||||||
using Deal.Tests.Unit.Modules.Kanban;
|
using Deal.Tests.Unit.Modules.Kanban;
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
using Deal.Tests.Unit.Modules.Tenants;
|
using Deal.Tests.Unit.Modules.Tenants;
|
||||||
@@ -40,11 +40,11 @@ public sealed class PipelineWorkerSchedulerTests
|
|||||||
// Контекст теста: планировщик на общих фейках + каналы подписок тенантов.
|
// Контекст теста: планировщик на общих фейках + каналы подписок тенантов.
|
||||||
private sealed record Context(
|
private sealed record Context(
|
||||||
PipelineWorkerScheduler Scheduler,
|
PipelineWorkerScheduler Scheduler,
|
||||||
FakePipelineStore PipelineA,
|
TestPipelineStore PipelineA,
|
||||||
FakeKanjStore KanjA,
|
TestKanjStore KanjA,
|
||||||
SseSubscription SubscriptionA,
|
SseSubscription SubscriptionA,
|
||||||
FakePipelineStore PipelineB,
|
TestPipelineStore PipelineB,
|
||||||
FakeKanjStore KanjB,
|
TestKanjStore KanjB,
|
||||||
SseSubscription SubscriptionB,
|
SseSubscription SubscriptionB,
|
||||||
PipelinePumpGate PumpGate,
|
PipelinePumpGate PumpGate,
|
||||||
TenantContext TenantContext,
|
TenantContext TenantContext,
|
||||||
@@ -150,12 +150,12 @@ public sealed class PipelineWorkerSchedulerTests
|
|||||||
{
|
{
|
||||||
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
|
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
|
||||||
var tenantContext = new TenantContext();
|
var tenantContext = new TenantContext();
|
||||||
FakePipelineStore pipelineA = withThrowingQueueReadA ? new ThrowingQueueReadPipelineStore() : new FakePipelineStore();
|
var pipelineA = new TestPipelineStore(throwOnList: withThrowingQueueReadA);
|
||||||
var pipelineB = new FakePipelineStore();
|
var pipelineB = new TestPipelineStore();
|
||||||
var kanjA = new FakeKanjStore();
|
var kanjA = new TestKanjStore();
|
||||||
var kanjB = new FakeKanjStore();
|
var kanjB = new TestKanjStore();
|
||||||
var settingsA = new FakeSettingsStore();
|
var settingsA = new TestSettingsStore();
|
||||||
var settingsB = new FakeSettingsStore();
|
var settingsB = new TestSettingsStore();
|
||||||
var pumpGate = new PipelinePumpGate();
|
var pumpGate = new PipelinePumpGate();
|
||||||
var aiClassifier = new TestAiClassifier
|
var aiClassifier = new TestAiClassifier
|
||||||
{
|
{
|
||||||
@@ -167,13 +167,13 @@ public sealed class PipelineWorkerSchedulerTests
|
|||||||
services.AddSingleton<SseBroker>();
|
services.AddSingleton<SseBroker>();
|
||||||
services.AddSingleton<ITenantRepository>(tenants.Repository);
|
services.AddSingleton<ITenantRepository>(tenants.Repository);
|
||||||
services.AddSingleton(pumpGate);
|
services.AddSingleton(pumpGate);
|
||||||
services.AddSingleton<IMlClient>(new FakeMlClient());
|
services.AddSingleton<IMlClient>(new TestMlClient().Client);
|
||||||
services.AddSingleton<IAiClassifier>(aiClassifier.Classifier);
|
services.AddSingleton<IAiClassifier>(aiClassifier.Classifier);
|
||||||
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
|
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
|
||||||
// (эталон StorageTickSchedulerTests/ConnectionStringProvider.ForTenant).
|
// (эталон StorageTickSchedulerTests/ConnectionStringProvider.ForTenant).
|
||||||
services.AddScoped<ICardStore>(provider => TenantOf(provider) == TenantA ? kanjA : kanjB);
|
services.AddScoped<ICardStore>(provider => TenantOf(provider) == TenantA ? kanjA.Store : kanjB.Store);
|
||||||
services.AddScoped<ISettingsStore>(provider => TenantOf(provider) == TenantA ? settingsA : settingsB);
|
services.AddScoped<ISettingsStore>(provider => TenantOf(provider) == TenantA ? settingsA.Store : settingsB.Store);
|
||||||
services.AddScoped<IPipelineStore>(provider => TenantOf(provider) == TenantA ? pipelineA : pipelineB);
|
services.AddScoped<IPipelineStore>(provider => TenantOf(provider) == TenantA ? pipelineA.Store : pipelineB.Store);
|
||||||
// Реальные сервисы модуля Pipeline — как AddPipelineModule в Program.cs: цикл резолвит их в tenant-scope.
|
// Реальные сервисы модуля Pipeline — как AddPipelineModule в Program.cs: цикл резолвит их в tenant-scope.
|
||||||
services.AddScoped<IncomingRules>();
|
services.AddScoped<IncomingRules>();
|
||||||
services.AddScoped<LocalFieldsParser>();
|
services.AddScoped<LocalFieldsParser>();
|
||||||
@@ -266,18 +266,7 @@ public sealed class PipelineWorkerSchedulerTests
|
|||||||
return cardIds;
|
return cardIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Хранилище со сбоем чтения очереди: ListAsync бросает (сценарий «БД/схема недоступны» на pump).
|
// Хранилище со сбоем чтения очереди задаётся флагом throwOnList в TestPipelineStore.
|
||||||
private sealed class ThrowingQueueReadPipelineStore : FakePipelineStore
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override Task<IReadOnlyList<QueueItemDto>> ListAsync(
|
|
||||||
string? status,
|
|
||||||
int limit,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync).");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Логгер-коллектор: копит сообщения планировщика (диагностика в тестах сбоя pump).
|
// Логгер-коллектор: копит сообщения планировщика (диагностика в тестах сбоя pump).
|
||||||
private sealed class ListLogger : ILogger<PipelineWorkerScheduler>
|
private sealed class ListLogger : ILogger<PipelineWorkerScheduler>
|
||||||
|
|||||||
@@ -1,12 +1,12 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Deal.Contracts.Integrations.Models;
|
using Deal.Contracts.Integrations.Models;
|
||||||
|
using Deal.Tests.Unit.Support;
|
||||||
using Deal.Modules.Kanban.Application.Models;
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
using Deal.Modules.Pipeline.Application.Models;
|
using Deal.Modules.Pipeline.Application.Models;
|
||||||
using Deal.Modules.Pipeline.Application.Parse;
|
using Deal.Modules.Pipeline.Application.Parse;
|
||||||
using Deal.Modules.Pipeline.Application.Services;
|
using Deal.Modules.Pipeline.Application.Services;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
using Deal.Tests.Unit.Contracts;
|
using Deal.Tests.Unit.Contracts;
|
||||||
using Deal.Tests.Unit.Modules.Cards;
|
|
||||||
using Deal.Tests.Unit.Modules.Kanban;
|
using Deal.Tests.Unit.Modules.Kanban;
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
|
|
||||||
@@ -24,29 +24,29 @@ public sealed class PipelineWorkerServiceTests
|
|||||||
// Контекст теста: воркер поверх in-memory фейков хранилищ/портов.
|
// Контекст теста: воркер поверх in-memory фейков хранилищ/портов.
|
||||||
private sealed record Context(
|
private sealed record Context(
|
||||||
PipelineWorkerService Worker,
|
PipelineWorkerService Worker,
|
||||||
FakePipelineStore PipelineStore,
|
TestPipelineStore PipelineStore,
|
||||||
FakeKanjStore KanjStore,
|
TestKanjStore KanjStore,
|
||||||
FakeSettingsStore Settings,
|
TestSettingsStore Settings,
|
||||||
FakeMlClient MlClient,
|
TestMlClient MlClient,
|
||||||
TestAiClassifier AiClassifier);
|
TestAiClassifier AiClassifier);
|
||||||
|
|
||||||
// Собирает контекст: дефолты настроек/досок, ML «не готов» (как LocalMlClient), ИИ-фильтр пропускает.
|
// Собирает контекст: дефолты настроек/досок, ML «не готов» (как LocalMlClient), ИИ-фильтр пропускает.
|
||||||
// pipelineStore: Хранилище очереди (по умолчанию — обычный фейк; сценарий гонки claim'а — RacingClaimPipelineStore).
|
// pipelineStore: Хранилище очереди (по умолчанию — обычный фейк; сценарий гонки claim'а — RacingClaimPipelineStore).
|
||||||
// Возвращает: Воркер и фейки (сценарий до-настраивает строки очереди/ответы портов).
|
// Возвращает: Воркер и фейки (сценарий до-настраивает строки очереди/ответы портов).
|
||||||
private static Context CreateContext(FakePipelineStore? pipelineStore = null)
|
private static Context CreateContext(TestPipelineStore? pipelineStore = null)
|
||||||
{
|
{
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
var store = pipelineStore ?? new FakePipelineStore();
|
var store = pipelineStore ?? new TestPipelineStore();
|
||||||
var kanjStore = new FakeKanjStore();
|
var kanjStore = new TestKanjStore();
|
||||||
var mlClient = new FakeMlClient { Predict = NotReadyPrediction() };
|
var mlClient = new TestMlClient { Predict = NotReadyPrediction() };
|
||||||
var aiClassifier = new TestAiClassifier();
|
var aiClassifier = new TestAiClassifier();
|
||||||
var rules = new IncomingRules(settings);
|
var rules = new IncomingRules(settings.Store);
|
||||||
var fieldsParser = new LocalFieldsParser(settings);
|
var fieldsParser = new LocalFieldsParser(settings.Store);
|
||||||
var processing = new PipelineProcessingService(store, mlClient, new PipelineIngestService(store));
|
var processing = new PipelineProcessingService(store.Store, mlClient.Client, new PipelineIngestService(store.Store));
|
||||||
var composer = new CardComposer(kanjStore, settings);
|
var composer = new CardComposer(kanjStore.Store, settings.Store);
|
||||||
var writer = new PipelineCardWriter(kanjStore, store, composer);
|
var writer = new PipelineCardWriter(kanjStore.Store, store.Store, composer);
|
||||||
var worker = new PipelineWorkerService(
|
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);
|
return new Context(worker, store, kanjStore, settings, mlClient, aiClassifier);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,38 +54,7 @@ public sealed class PipelineWorkerServiceTests
|
|||||||
// видит заявку, но ClaimAsync (INSERT … ON CONFLICT DO NOTHING в адаптере) возвращает false — хэш успел
|
// видит заявку, но ClaimAsync (INSERT … ON CONFLICT DO NOTHING в адаптере) возвращает false — хэш успел
|
||||||
// заявить другой проход. DeleteClaimAsync записывается — проверяем, что чужая заявка НЕ снимается.
|
// заявить другой проход. DeleteClaimAsync записывается — проверяем, что чужая заявка НЕ снимается.
|
||||||
// claimResult: Результат ClaimAsync (по умолчанию false — «победил другой проход»).
|
// 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).
|
// Сериализует значение настройки в JSON-строку строки settings (как пишет SettingsStore).
|
||||||
// value: Значение (число/булево/строка/список).
|
// value: Значение (число/булево/строка/список).
|
||||||
@@ -284,13 +253,13 @@ public sealed class PipelineWorkerServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task Pump_ClaimLostToParallelPass_RejectsDuplicateAndDoesNotCreateCard()
|
public async Task Pump_ClaimLostToParallelPass_RejectsDuplicateAndDoesNotCreateCard()
|
||||||
{
|
{
|
||||||
Context ctx = CreateContext(new RacingClaimPipelineStore());
|
Context ctx = CreateContext(new TestPipelineStore(claimRace: true));
|
||||||
const string text = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц";
|
const string text = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц";
|
||||||
ctx.PipelineStore.SeedQueue(QueueRow("p_1", text));
|
ctx.PipelineStore.SeedQueue(QueueRow("p_1", text));
|
||||||
|
|
||||||
PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default);
|
PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default);
|
||||||
|
|
||||||
RacingClaimPipelineStore store = Assert.IsType<RacingClaimPipelineStore>(ctx.PipelineStore);
|
TestPipelineStore store = ctx.PipelineStore;
|
||||||
Assert.Equal(1, store.ClaimCalls); // конфликт claim'а обработан, а не пропущен молча
|
Assert.Equal(1, store.ClaimCalls); // конфликт claim'а обработан, а не пропущен молча
|
||||||
RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected);
|
RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected);
|
||||||
Assert.Equal("dup", rejected.Stage);
|
Assert.Equal("dup", rejected.Stage);
|
||||||
@@ -377,7 +346,7 @@ public sealed class PipelineWorkerServiceTests
|
|||||||
Assert.Empty(ctx.KanjStore.CardDtos);
|
Assert.Empty(ctx.KanjStore.CardDtos);
|
||||||
Assert.Equal(1, result.NoBudget);
|
Assert.Equal(1, result.NoBudget);
|
||||||
Assert.Equal(0, result.AiStored); // отсев фильтром не считается решением ИИ (python L1143–1147)
|
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));
|
Assert.Null(ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -615,7 +584,7 @@ public sealed class PipelineWorkerServiceTests
|
|||||||
|
|
||||||
QueueItemDto row = Assert.Single(ctx.PipelineStore.Queue);
|
QueueItemDto row = Assert.Single(ctx.PipelineStore.Queue);
|
||||||
Assert.Equal(PipelineQueueStatuses.Filtered, row.Status); // прошла «new»-проход, ИИ-шаг не завершился
|
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.PipelineStore.Rejected);
|
||||||
Assert.Empty(ctx.KanjStore.CardDtos);
|
Assert.Empty(ctx.KanjStore.CardDtos);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Deal.Modules.Settings.Application.Abstractions;
|
using Deal.Modules.Settings.Application.Abstractions;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
|
using Deal.Tests.Unit.Support;
|
||||||
using Deal.Modules.Settings.Application.Services;
|
using Deal.Modules.Settings.Application.Services;
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
@@ -14,7 +15,7 @@ public sealed class RatesServiceTests
|
|||||||
{
|
{
|
||||||
private const long FixedTimestampMs = 1_752_000_000_000;
|
private const long FixedTimestampMs = 1_752_000_000_000;
|
||||||
|
|
||||||
private readonly FakeSettingsStore _store = new();
|
private readonly TestSettingsStore _store = new();
|
||||||
|
|
||||||
// ─── GetAsync: кэш и дефолт ─────────────────────────────────────────────
|
// ─── GetAsync: кэш и дефолт ─────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -418,7 +419,7 @@ public sealed class RatesServiceTests
|
|||||||
|
|
||||||
private RatesService CreateService(IRatesSource source, IReadOnlyList<IRatesChangedListener> listeners)
|
private RatesService CreateService(IRatesSource source, IReadOnlyList<IRatesChangedListener> listeners)
|
||||||
{
|
{
|
||||||
return new RatesService(_store, source, listeners);
|
return new RatesService(_store.Store, source, listeners);
|
||||||
}
|
}
|
||||||
|
|
||||||
// JSON значения ratesCache с одним курсом USD и заданным временем/источником.
|
// JSON значения ratesCache с одним курсом USD и заданным временем/источником.
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using Deal.Modules.Settings.Application.Abstractions;
|
using Deal.Modules.Settings.Application.Abstractions;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
|
using Deal.Tests.Unit.Support;
|
||||||
using Deal.Modules.Settings.Application.Services;
|
using Deal.Modules.Settings.Application.Services;
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
using NSubstitute;
|
using NSubstitute;
|
||||||
@@ -12,13 +13,13 @@ namespace Deal.Tests.Unit.Support;
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class SettingsServiceTests
|
public sealed class SettingsServiceTests
|
||||||
{
|
{
|
||||||
private readonly FakeSettingsStore _store = new();
|
private readonly TestSettingsStore _store = new();
|
||||||
private readonly ISecretCipher _cipher = TestCiphers.New();
|
private readonly ISecretCipher _cipher = TestCiphers.New();
|
||||||
private readonly SettingsService _service;
|
private readonly SettingsService _service;
|
||||||
|
|
||||||
public SettingsServiceTests()
|
public SettingsServiceTests()
|
||||||
{
|
{
|
||||||
_service = new SettingsService(_store, _cipher, Array.Empty<IRatesChangedListener>());
|
_service = new SettingsService(_store.Store, _cipher, Array.Empty<IRatesChangedListener>());
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── GET: снимок ─────────────────────────────────────────────────────────
|
// ─── GET: снимок ─────────────────────────────────────────────────────────
|
||||||
@@ -592,7 +593,7 @@ public sealed class SettingsServiceTests
|
|||||||
public async Task ApplyPatchAsync_TargetCurrencyInBody_NotifiesListenersAfterSave()
|
public async Task ApplyPatchAsync_TargetCurrencyInBody_NotifiesListenersAfterSave()
|
||||||
{
|
{
|
||||||
var listener = Substitute.For<IRatesChangedListener>();
|
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);
|
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()
|
public async Task ApplyPatchAsync_ConversionOnInBody_NotifiesListeners()
|
||||||
{
|
{
|
||||||
var listener = Substitute.For<IRatesChangedListener>();
|
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);
|
await local.ApplyPatchAsync(JsonBody(new { conversionOn = false }), CancellationToken.None);
|
||||||
|
|
||||||
@@ -617,7 +618,7 @@ public sealed class SettingsServiceTests
|
|||||||
public async Task ApplyPatchAsync_UnrelatedKeys_DoesNotNotifyListeners()
|
public async Task ApplyPatchAsync_UnrelatedKeys_DoesNotNotifyListeners()
|
||||||
{
|
{
|
||||||
var listener = Substitute.For<IRatesChangedListener>();
|
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);
|
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()
|
public async Task ApplyPatchAsync_TargetCurrencyJsonNull_DoesNotNotifyListeners()
|
||||||
{
|
{
|
||||||
var listener = Substitute.For<IRatesChangedListener>();
|
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);
|
await local.ApplyPatchAsync(JsonBody(new Dictionary<string, object?> { ["targetCurrency"] = null }), CancellationToken.None);
|
||||||
|
|
||||||
|
|||||||
@@ -46,12 +46,12 @@ public sealed class SourceIngressGrpcServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PushSource_ValidTenant_EnqueuesQueueRowAndAccepts()
|
public async Task PushSource_ValidTenant_EnqueuesQueueRowAndAccepts()
|
||||||
{
|
{
|
||||||
var store = new FakePipelineStore();
|
var store = new TestPipelineStore();
|
||||||
var registry = TestRegistry.With(Tenant(TenantA));
|
var registry = TestRegistry.With(Tenant(TenantA));
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
registry,
|
registry,
|
||||||
services => services.AddScoped<IPipelineStore>(_ => store),
|
services => services.AddScoped<IPipelineStore>(_ => store.Store),
|
||||||
async channel =>
|
async channel =>
|
||||||
{
|
{
|
||||||
PushSourceReply reply = await PushAsync(
|
PushSourceReply reply = await PushAsync(
|
||||||
@@ -80,12 +80,12 @@ public sealed class SourceIngressGrpcServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PushSource_SameSourceKeyTwice_SecondIsDuplicateAndQueueNotGrown()
|
public async Task PushSource_SameSourceKeyTwice_SecondIsDuplicateAndQueueNotGrown()
|
||||||
{
|
{
|
||||||
var store = new FakePipelineStore();
|
var store = new TestPipelineStore();
|
||||||
var registry = TestRegistry.With(Tenant(TenantA));
|
var registry = TestRegistry.With(Tenant(TenantA));
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
registry,
|
registry,
|
||||||
services => services.AddScoped<IPipelineStore>(_ => store),
|
services => services.AddScoped<IPipelineStore>(_ => store.Store),
|
||||||
async channel =>
|
async channel =>
|
||||||
{
|
{
|
||||||
PushSourceReply first = await PushAsync(channel, Request("Сообщение канала", externalId: "42"), TenantA, ValidToken);
|
PushSourceReply first = await PushAsync(channel, Request("Сообщение канала", externalId: "42"), TenantA, ValidToken);
|
||||||
@@ -104,12 +104,12 @@ public sealed class SourceIngressGrpcServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PushSource_EmptyText_NotAcceptedAndQueueNotGrown()
|
public async Task PushSource_EmptyText_NotAcceptedAndQueueNotGrown()
|
||||||
{
|
{
|
||||||
var store = new FakePipelineStore();
|
var store = new TestPipelineStore();
|
||||||
var registry = TestRegistry.With(Tenant(TenantA));
|
var registry = TestRegistry.With(Tenant(TenantA));
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
registry,
|
registry,
|
||||||
services => services.AddScoped<IPipelineStore>(_ => store),
|
services => services.AddScoped<IPipelineStore>(_ => store.Store),
|
||||||
async channel =>
|
async channel =>
|
||||||
{
|
{
|
||||||
PushSourceReply reply = await PushAsync(channel, Request(" ", externalId: "9"), TenantA, ValidToken);
|
PushSourceReply reply = await PushAsync(channel, Request(" ", externalId: "9"), TenantA, ValidToken);
|
||||||
@@ -126,12 +126,12 @@ public sealed class SourceIngressGrpcServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PushSource_UnknownTenant_NotAcceptedWithoutRpcError()
|
public async Task PushSource_UnknownTenant_NotAcceptedWithoutRpcError()
|
||||||
{
|
{
|
||||||
var store = new FakePipelineStore();
|
var store = new TestPipelineStore();
|
||||||
var registry = TestRegistry.With(Tenant(TenantA));
|
var registry = TestRegistry.With(Tenant(TenantA));
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
registry,
|
registry,
|
||||||
services => services.AddScoped<IPipelineStore>(_ => store),
|
services => services.AddScoped<IPipelineStore>(_ => store.Store),
|
||||||
async channel =>
|
async channel =>
|
||||||
{
|
{
|
||||||
PushSourceReply reply = await PushAsync(channel, Request("Сообщение чужого тенанта", externalId: "5"), Guid.NewGuid(), ValidToken);
|
PushSourceReply reply = await PushAsync(channel, Request("Сообщение чужого тенанта", externalId: "5"), Guid.NewGuid(), ValidToken);
|
||||||
@@ -150,10 +150,10 @@ public sealed class SourceIngressGrpcServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PushSource_WithoutToken_IsUnauthenticated()
|
public async Task PushSource_WithoutToken_IsUnauthenticated()
|
||||||
{
|
{
|
||||||
var store = new FakePipelineStore();
|
var store = new TestPipelineStore();
|
||||||
var registry = TestRegistry.With(Tenant(TenantA));
|
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>(
|
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||||
() => PushAsync(channel, Request("текст", externalId: "1"), TenantA, null));
|
() => PushAsync(channel, Request("текст", externalId: "1"), TenantA, null));
|
||||||
@@ -167,10 +167,10 @@ public sealed class SourceIngressGrpcServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PushSource_WithWrongToken_IsUnauthenticated()
|
public async Task PushSource_WithWrongToken_IsUnauthenticated()
|
||||||
{
|
{
|
||||||
var store = new FakePipelineStore();
|
var store = new TestPipelineStore();
|
||||||
var registry = TestRegistry.With(Tenant(TenantA));
|
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>(
|
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||||
() => PushAsync(channel, Request("текст", externalId: "2"), TenantA, "wrong-token"));
|
() => PushAsync(channel, Request("текст", externalId: "2"), TenantA, "wrong-token"));
|
||||||
@@ -184,7 +184,7 @@ public sealed class SourceIngressGrpcServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PushSource_UnsetEnvToken_FailsClosed()
|
public async Task PushSource_UnsetEnvToken_FailsClosed()
|
||||||
{
|
{
|
||||||
var store = new FakePipelineStore();
|
var store = new TestPipelineStore();
|
||||||
var registry = TestRegistry.With(Tenant(TenantA));
|
var registry = TestRegistry.With(Tenant(TenantA));
|
||||||
|
|
||||||
await TelegramIngressTestHost.RunAsync(
|
await TelegramIngressTestHost.RunAsync(
|
||||||
@@ -192,7 +192,7 @@ public sealed class SourceIngressGrpcServiceTests
|
|||||||
configureServices: services =>
|
configureServices: services =>
|
||||||
{
|
{
|
||||||
services.AddSingleton<ITenantRepository>(registry);
|
services.AddSingleton<ITenantRepository>(registry);
|
||||||
services.AddScoped<IPipelineStore>(_ => store);
|
services.AddScoped<IPipelineStore>(_ => store.Store);
|
||||||
},
|
},
|
||||||
scenario: async channel =>
|
scenario: async channel =>
|
||||||
{
|
{
|
||||||
@@ -208,10 +208,10 @@ public sealed class SourceIngressGrpcServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task PushSource_WithoutTenantIdMetadata_IsUnauthenticated()
|
public async Task PushSource_WithoutTenantIdMetadata_IsUnauthenticated()
|
||||||
{
|
{
|
||||||
var store = new FakePipelineStore();
|
var store = new TestPipelineStore();
|
||||||
var registry = TestRegistry.With(Tenant(TenantA));
|
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>(
|
RpcException exception = await Assert.ThrowsAsync<RpcException>(
|
||||||
() => PushAsync(channel, Request("текст", externalId: "4"), null, ValidToken));
|
() => 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.Pipeline.Application.Services;
|
||||||
using Deal.Modules.Settings.Application.Abstractions;
|
using Deal.Modules.Settings.Application.Abstractions;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
|
using Deal.Tests.Unit.Support;
|
||||||
using Deal.Modules.Tenants.Application.Abstractions;
|
using Deal.Modules.Tenants.Application.Abstractions;
|
||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
using Deal.SharedKernel.Tenants.Abstractions;
|
using Deal.SharedKernel.Tenants.Abstractions;
|
||||||
using Deal.SharedKernel.Tenants.Models;
|
using Deal.SharedKernel.Tenants.Models;
|
||||||
using Deal.Tests.Unit.Contracts;
|
using Deal.Tests.Unit.Contracts;
|
||||||
using Deal.Tests.Unit.Modules.Cards;
|
|
||||||
using Deal.Tests.Unit.Modules.Kanban;
|
using Deal.Tests.Unit.Modules.Kanban;
|
||||||
using Deal.Tests.Unit.Modules.Settings;
|
using Deal.Tests.Unit.Modules.Settings;
|
||||||
using Deal.Tests.Unit.Modules.Tenants;
|
using Deal.Tests.Unit.Modules.Tenants;
|
||||||
@@ -42,9 +42,9 @@ public sealed class StorageTickSchedulerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunCycle_TicksEveryTenantInOwnScopeAndPublishesToastToEachTenantChannel()
|
public async Task RunCycle_TicksEveryTenantInOwnScopeAndPublishesToastToEachTenantChannel()
|
||||||
{
|
{
|
||||||
FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old");
|
TestKanjStore storeA = StoreWithExpiredInboxCard("l_a_old");
|
||||||
FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
|
TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
|
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
|
||||||
var tenantContext = new TenantContext();
|
var tenantContext = new TenantContext();
|
||||||
await using ServiceProvider provider = BuildProvider(tenants, tenantContext, storeA, storeB, settings);
|
await using ServiceProvider provider = BuildProvider(tenants, tenantContext, storeA, storeB, settings);
|
||||||
@@ -70,10 +70,10 @@ public sealed class StorageTickSchedulerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunCycle_ZeroCountersTenant_PublishesToastOnlyToTenantWithChanges()
|
public async Task RunCycle_ZeroCountersTenant_PublishesToastOnlyToTenantWithChanges()
|
||||||
{
|
{
|
||||||
FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old");
|
TestKanjStore storeA = StoreWithExpiredInboxCard("l_a_old");
|
||||||
var storeB = new FakeKanjStore();
|
var storeB = new TestKanjStore();
|
||||||
storeB.SeedCard(Card("l_b_fresh", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromHours(1))));
|
storeB.SeedCard(Card("l_b_fresh", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromHours(1))));
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
var tenantContext = new TenantContext();
|
var tenantContext = new TenantContext();
|
||||||
await using ServiceProvider provider = BuildProvider(
|
await using ServiceProvider provider = BuildProvider(
|
||||||
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)),
|
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)),
|
||||||
@@ -99,16 +99,16 @@ public sealed class StorageTickSchedulerTests
|
|||||||
public async Task RunCycle_TenantTickFailure_DoesNotAbortOtherTenants()
|
public async Task RunCycle_TenantTickFailure_DoesNotAbortOtherTenants()
|
||||||
{
|
{
|
||||||
// У тенанта A настройки падают (имитация сбоя схемы/БД) — тик A логирует ошибку, B обрабатывается.
|
// У тенанта A настройки падают (имитация сбоя схемы/БД) — тик A логирует ошибку, B обрабатывается.
|
||||||
FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
|
TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
|
||||||
var tenantContext = new TenantContext();
|
var tenantContext = new TenantContext();
|
||||||
var settingsByTenant = new Dictionary<Guid, ISettingsStore>
|
var settingsByTenant = new Dictionary<Guid, ISettingsStore>
|
||||||
{
|
{
|
||||||
[TenantA] = new ThrowingSettingsStore(),
|
[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,
|
[TenantB] = storeB,
|
||||||
};
|
};
|
||||||
await using ServiceProvider provider = BuildProvider(
|
await using ServiceProvider provider = BuildProvider(
|
||||||
@@ -137,7 +137,7 @@ public sealed class StorageTickSchedulerTests
|
|||||||
await using ServiceProvider provider = BuildProvider(
|
await using ServiceProvider provider = BuildProvider(
|
||||||
new ThrowingTenantRepository(),
|
new ThrowingTenantRepository(),
|
||||||
tenantContext,
|
tenantContext,
|
||||||
new Dictionary<Guid, FakeKanjStore>(),
|
new Dictionary<Guid, TestKanjStore>(),
|
||||||
new Dictionary<Guid, ISettingsStore>());
|
new Dictionary<Guid, ISettingsStore>());
|
||||||
StorageTickScheduler scheduler = CreateScheduler(provider);
|
StorageTickScheduler scheduler = CreateScheduler(provider);
|
||||||
|
|
||||||
@@ -150,19 +150,19 @@ public sealed class StorageTickSchedulerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunCycle_PurgesExpiredRejectedRowsAndPublishesRejectedPurgeToast()
|
public async Task RunCycle_PurgesExpiredRejectedRowsAndPublishesRejectedPurgeToast()
|
||||||
{
|
{
|
||||||
var kanjStore = new FakeKanjStore();
|
var kanjStore = new TestKanjStore();
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
var tenantContext = new TenantContext();
|
var tenantContext = new TenantContext();
|
||||||
var pipelineStoreA = new FakePipelineStore();
|
var pipelineStoreA = new TestPipelineStore();
|
||||||
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
pipelineStoreA.SeedRejected(Rejected("r_old_a", (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds)));
|
pipelineStoreA.SeedRejected(Rejected("r_old_a", (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds)));
|
||||||
pipelineStoreA.SeedRejected(Rejected("r_fresh", (long)(nowMs - TimeSpan.FromDays(1).TotalMilliseconds)));
|
pipelineStoreA.SeedRejected(Rejected("r_fresh", (long)(nowMs - TimeSpan.FromDays(1).TotalMilliseconds)));
|
||||||
await using ServiceProvider provider = BuildProvider(
|
await using ServiceProvider provider = BuildProvider(
|
||||||
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
|
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
|
||||||
tenantContext,
|
tenantContext,
|
||||||
new Dictionary<Guid, FakeKanjStore> { [TenantA] = kanjStore, [TenantB] = new FakeKanjStore() },
|
new Dictionary<Guid, TestKanjStore> { [TenantA] = kanjStore, [TenantB] = new TestKanjStore() },
|
||||||
new Dictionary<Guid, ISettingsStore> { [TenantA] = settings, [TenantB] = settings },
|
new Dictionary<Guid, ISettingsStore> { [TenantA] = settings.Store, [TenantB] = settings.Store },
|
||||||
new Dictionary<Guid, FakePipelineStore> { [TenantA] = pipelineStoreA, [TenantB] = new FakePipelineStore() });
|
new Dictionary<Guid, TestPipelineStore> { [TenantA] = pipelineStoreA, [TenantB] = new TestPipelineStore() });
|
||||||
|
|
||||||
SseBroker broker = provider.GetRequiredService<SseBroker>();
|
SseBroker broker = provider.GetRequiredService<SseBroker>();
|
||||||
SseSubscription subscriptionA = broker.Subscribe(TenantA);
|
SseSubscription subscriptionA = broker.Subscribe(TenantA);
|
||||||
@@ -181,18 +181,18 @@ public sealed class StorageTickSchedulerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunCycle_DueReminder_PublishesReminderDueToTenantChannelAndMarksFired()
|
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_past", title: "Отложенный бот", reminderAtMs: NowMs() - 60_000));
|
||||||
cardStoreA.SeedCard(HoldCard("c_a_future", 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));
|
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();
|
var tenantContext = new TenantContext();
|
||||||
await using ServiceProvider provider = BuildProvider(
|
await using ServiceProvider provider = BuildProvider(
|
||||||
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
|
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
|
||||||
tenantContext,
|
tenantContext,
|
||||||
new Dictionary<Guid, FakeKanjStore> { [TenantA] = cardStoreA, [TenantB] = cardStoreB },
|
new Dictionary<Guid, TestKanjStore> { [TenantA] = cardStoreA, [TenantB] = cardStoreB },
|
||||||
new Dictionary<Guid, ISettingsStore> { [TenantA] = new FakeSettingsStore(), [TenantB] = new FakeSettingsStore() },
|
new Dictionary<Guid, ISettingsStore> { [TenantA] = new TestSettingsStore().Store, [TenantB] = new TestSettingsStore().Store },
|
||||||
new Dictionary<Guid, FakePipelineStore> { [TenantA] = new(), [TenantB] = new() });
|
new Dictionary<Guid, TestPipelineStore> { [TenantA] = new(), [TenantB] = new() });
|
||||||
|
|
||||||
SseBroker broker = provider.GetRequiredService<SseBroker>();
|
SseBroker broker = provider.GetRequiredService<SseBroker>();
|
||||||
SseSubscription subscriptionA = broker.Subscribe(TenantA);
|
SseSubscription subscriptionA = broker.Subscribe(TenantA);
|
||||||
@@ -210,24 +210,24 @@ public sealed class StorageTickSchedulerTests
|
|||||||
Assert.False(subscriptionB.Events.TryRead(out _));
|
Assert.False(subscriptionB.Events.TryRead(out _));
|
||||||
|
|
||||||
// «Выстрелившее» помечено fired: повторная выборка due пуста (признак держит строка БД).
|
// «Выстрелившее» помечено 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);
|
Assert.False(tenantContext.HasTenant);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunCycle_RemindersDisabled_ClearsExpiredAndPublishesNoReminderDue()
|
public async Task RunCycle_RemindersDisabled_ClearsExpiredAndPublishesNoReminderDue()
|
||||||
{
|
{
|
||||||
var cardStoreA = new FakeKanjStore();
|
var cardStoreA = new TestKanjStore();
|
||||||
cardStoreA.SeedCard(HoldCard("c_past", title: "Старое", reminderAtMs: NowMs() - 60_000));
|
cardStoreA.SeedCard(HoldCard("c_past", title: "Старое", reminderAtMs: NowMs() - 60_000));
|
||||||
var settingsA = new FakeSettingsStore();
|
var settingsA = new TestSettingsStore();
|
||||||
settingsA.Preload(SettingsKeys.RemindersEnabled, "false");
|
settingsA.Preload(SettingsKeys.RemindersEnabled, "false");
|
||||||
var tenantContext = new TenantContext();
|
var tenantContext = new TenantContext();
|
||||||
await using ServiceProvider provider = BuildProvider(
|
await using ServiceProvider provider = BuildProvider(
|
||||||
new TestTenantRepository(Tenant(TenantA)).Repository,
|
new TestTenantRepository(Tenant(TenantA)).Repository,
|
||||||
tenantContext,
|
tenantContext,
|
||||||
new Dictionary<Guid, FakeKanjStore> { [TenantA] = cardStoreA },
|
new Dictionary<Guid, TestKanjStore> { [TenantA] = cardStoreA },
|
||||||
new Dictionary<Guid, ISettingsStore> { [TenantA] = settingsA },
|
new Dictionary<Guid, ISettingsStore> { [TenantA] = settingsA.Store },
|
||||||
new Dictionary<Guid, FakePipelineStore> { [TenantA] = new() });
|
new Dictionary<Guid, TestPipelineStore> { [TenantA] = new() });
|
||||||
|
|
||||||
SseBroker broker = provider.GetRequiredService<SseBroker>();
|
SseBroker broker = provider.GetRequiredService<SseBroker>();
|
||||||
SseSubscription subscriptionA = broker.Subscribe(TenantA);
|
SseSubscription subscriptionA = broker.Subscribe(TenantA);
|
||||||
@@ -245,15 +245,15 @@ public sealed class StorageTickSchedulerTests
|
|||||||
{
|
{
|
||||||
// У тенанта A проверка напоминаний падает (имитация сбоя схемы/БД на ListDueAsync) — ветка логируется
|
// У тенанта A проверка напоминаний падает (имитация сбоя схемы/БД на ListDueAsync) — ветка логируется
|
||||||
// и тик A завершается, тенант B обрабатывается (автоархив + тост) — проход жив.
|
// и тик A завершается, тенант B обрабатывается (автоархив + тост) — проход жив.
|
||||||
var cardStoreA = new ThrowingDueKanjStore();
|
var cardStoreA = new TestKanjStore(throwOnDueReminders: true);
|
||||||
FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
|
TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
|
||||||
var tenantContext = new TenantContext();
|
var tenantContext = new TenantContext();
|
||||||
await using ServiceProvider provider = BuildProvider(
|
await using ServiceProvider provider = BuildProvider(
|
||||||
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
|
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
|
||||||
tenantContext,
|
tenantContext,
|
||||||
new Dictionary<Guid, FakeKanjStore> { [TenantA] = cardStoreA, [TenantB] = storeB },
|
new Dictionary<Guid, TestKanjStore> { [TenantA] = cardStoreA, [TenantB] = storeB },
|
||||||
new Dictionary<Guid, ISettingsStore> { [TenantA] = new FakeSettingsStore(), [TenantB] = new FakeSettingsStore() },
|
new Dictionary<Guid, ISettingsStore> { [TenantA] = new TestSettingsStore().Store, [TenantB] = new TestSettingsStore().Store },
|
||||||
new Dictionary<Guid, FakePipelineStore> { [TenantA] = new(), [TenantB] = new() });
|
new Dictionary<Guid, TestPipelineStore> { [TenantA] = new(), [TenantB] = new() });
|
||||||
|
|
||||||
SseBroker broker = provider.GetRequiredService<SseBroker>();
|
SseBroker broker = provider.GetRequiredService<SseBroker>();
|
||||||
SseSubscription subscriptionA = broker.Subscribe(TenantA);
|
SseSubscription subscriptionA = broker.Subscribe(TenantA);
|
||||||
@@ -281,15 +281,15 @@ public sealed class StorageTickSchedulerTests
|
|||||||
private static ServiceProvider BuildProvider(
|
private static ServiceProvider BuildProvider(
|
||||||
TestTenantRepository tenants,
|
TestTenantRepository tenants,
|
||||||
TenantContext tenantContext,
|
TenantContext tenantContext,
|
||||||
FakeKanjStore storeA,
|
TestKanjStore storeA,
|
||||||
FakeKanjStore storeB,
|
TestKanjStore storeB,
|
||||||
FakeSettingsStore settings)
|
TestSettingsStore settings)
|
||||||
{
|
{
|
||||||
return BuildProvider(
|
return BuildProvider(
|
||||||
tenants.Repository,
|
tenants.Repository,
|
||||||
tenantContext,
|
tenantContext,
|
||||||
new Dictionary<Guid, FakeKanjStore> { [TenantA] = storeA, [TenantB] = storeB },
|
new Dictionary<Guid, TestKanjStore> { [TenantA] = storeA, [TenantB] = storeB },
|
||||||
new Dictionary<Guid, ISettingsStore> { [TenantA] = settings, [TenantB] = settings });
|
new Dictionary<Guid, ISettingsStore> { [TenantA] = settings.Store, [TenantB] = settings.Store });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Строит DI-провайдер теста по явным картам тенант → хранилище/настройки.
|
// Строит DI-провайдер теста по явным картам тенант → хранилище/настройки.
|
||||||
@@ -302,14 +302,14 @@ public sealed class StorageTickSchedulerTests
|
|||||||
private static ServiceProvider BuildProvider(
|
private static ServiceProvider BuildProvider(
|
||||||
ITenantRepository tenants,
|
ITenantRepository tenants,
|
||||||
TenantContext tenantContext,
|
TenantContext tenantContext,
|
||||||
Dictionary<Guid, FakeKanjStore> storesByTenant,
|
Dictionary<Guid, TestKanjStore> storesByTenant,
|
||||||
Dictionary<Guid, ISettingsStore> settingsByTenant,
|
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(),
|
[TenantA] = new TestPipelineStore(),
|
||||||
[TenantB] = new FakePipelineStore(),
|
[TenantB] = new TestPipelineStore(),
|
||||||
};
|
};
|
||||||
|
|
||||||
var services = new ServiceCollection();
|
var services = new ServiceCollection();
|
||||||
@@ -319,10 +319,10 @@ public sealed class StorageTickSchedulerTests
|
|||||||
services.AddSingleton(tenants);
|
services.AddSingleton(tenants);
|
||||||
// Тенант-scoped адаптеры: реальные строят TenantDbContext по схеме текущего тенанта — фейк
|
// Тенант-scoped адаптеры: реальные строят TenantDbContext по схеме текущего тенанта — фейк
|
||||||
// выбирает хранилище по тому же ITenantContext, который планировщик заполняет SetTenant.
|
// выбирает хранилище по тому же 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<ISettingsStore>(provider => settingsByTenant[TenantOf(provider)]);
|
||||||
services.AddScoped<IPipelineStore>(provider => pipelineStoresByTenant[TenantOf(provider)]);
|
services.AddScoped<IPipelineStore>(provider => pipelineStoresByTenant[TenantOf(provider)].Store);
|
||||||
services.AddSingleton<IMlClient>(new FakeMlClient());
|
services.AddSingleton<IMlClient>(new TestMlClient().Client);
|
||||||
services.AddSingleton<IFileStorage>(new TestFileStorage().Storage);
|
services.AddSingleton<IFileStorage>(new TestFileStorage().Storage);
|
||||||
services.AddScoped<PipelineIngestService>();
|
services.AddScoped<PipelineIngestService>();
|
||||||
services.AddScoped<PipelineProcessingService>();
|
services.AddScoped<PipelineProcessingService>();
|
||||||
@@ -359,17 +359,7 @@ public sealed class StorageTickSchedulerTests
|
|||||||
private static TenantRecordDto Tenant(Guid id) =>
|
private static TenantRecordDto Tenant(Guid id) =>
|
||||||
new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow);
|
new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow);
|
||||||
|
|
||||||
// Хранилище карточек со сбоем выборки due-напоминаний: ListDueRemindersAsync бросает (сценарий
|
// Сбой выборки due-напоминаний задаётся флагом throwOnDueReminders в TestKanjStore.
|
||||||
// «БД/схема недоступны» на проверке напоминаний — ветка логируется, тик тенанта/проход живы, как в
|
|
||||||
// AdminTickOrchestratorTests).
|
|
||||||
private sealed class ThrowingDueKanjStore : FakeKanjStore
|
|
||||||
{
|
|
||||||
/// <inheritdoc />
|
|
||||||
public override Task<IReadOnlyList<CardReminderDueDto>> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct)
|
|
||||||
{
|
|
||||||
throw new InvalidOperationException("Тестовый сбой выборки due-напоминаний (ListDueRemindersAsync).");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем.
|
// Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем.
|
||||||
private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
|
||||||
@@ -394,9 +384,9 @@ public sealed class StorageTickSchedulerTests
|
|||||||
// Хранилище с просроченной карточкой «Неразобранного» — кандидатом автоархива дефолтного тика.
|
// Хранилище с просроченной карточкой «Неразобранного» — кандидатом автоархива дефолтного тика.
|
||||||
// cardId: Id карточки.
|
// cardId: Id карточки.
|
||||||
// Возвращает: Фейк-хранилище с одной старой карточкой inbox.
|
// Возвращает: Фейк-хранилище с одной старой карточкой 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)));
|
store.SeedCard(Card(cardId, KanbanColumns.Inbox, ReceivedAtMsAgo(ExpiredAge)));
|
||||||
return store;
|
return store;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Deal.Api.Events;
|
|||||||
using Deal.Grpc.Telegram;
|
using Deal.Grpc.Telegram;
|
||||||
using Deal.Modules.Settings.Application.Abstractions;
|
using Deal.Modules.Settings.Application.Abstractions;
|
||||||
using Deal.Modules.Settings.Application.Models;
|
using Deal.Modules.Settings.Application.Models;
|
||||||
|
using Deal.Tests.Unit.Support;
|
||||||
using Deal.Modules.Telegram.Application;
|
using Deal.Modules.Telegram.Application;
|
||||||
using Deal.Modules.Tenants.Application.Abstractions;
|
using Deal.Modules.Tenants.Application.Abstractions;
|
||||||
using Deal.Modules.Tenants.Application.Models;
|
using Deal.Modules.Tenants.Application.Models;
|
||||||
@@ -61,7 +62,7 @@ public sealed class TelegramIngressServiceTests
|
|||||||
public async Task SyncDialogs_AutoMonitorNewDisabled_ReturnsEmptyMonitoredMirror()
|
public async Task SyncDialogs_AutoMonitorNewDisabled_ReturnsEmptyMonitoredMirror()
|
||||||
{
|
{
|
||||||
var registry = TestRegistry.With(Tenant(TenantA));
|
var registry = TestRegistry.With(Tenant(TenantA));
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
settings.Preload(SettingsKeys.AutoMonitorNew, "false");
|
settings.Preload(SettingsKeys.AutoMonitorNew, "false");
|
||||||
var dialogs = new TestTelegramStore();
|
var dialogs = new TestTelegramStore();
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ public sealed class TelegramIngressServiceTests
|
|||||||
registry,
|
registry,
|
||||||
services =>
|
services =>
|
||||||
{
|
{
|
||||||
services.AddScoped<ISettingsStore>(_ => settings);
|
services.AddScoped<ISettingsStore>(_ => settings.Store);
|
||||||
services.AddScoped<ITelegramStore>(_ => dialogs.Store);
|
services.AddScoped<ITelegramStore>(_ => dialogs.Store);
|
||||||
},
|
},
|
||||||
async channel =>
|
async channel =>
|
||||||
@@ -91,7 +92,7 @@ public sealed class TelegramIngressServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReportStatus_PersistsStatusAndPublishesTransitionToasts()
|
public async Task ReportStatus_PersistsStatusAndPublishesTransitionToasts()
|
||||||
{
|
{
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
var registry = TestRegistry.With(Tenant(TenantA));
|
var registry = TestRegistry.With(Tenant(TenantA));
|
||||||
var broker = new SseBroker();
|
var broker = new SseBroker();
|
||||||
|
|
||||||
@@ -100,7 +101,7 @@ public sealed class TelegramIngressServiceTests
|
|||||||
services =>
|
services =>
|
||||||
{
|
{
|
||||||
services.AddSingleton<SseBroker>(broker);
|
services.AddSingleton<SseBroker>(broker);
|
||||||
services.AddScoped<ISettingsStore>(_ => settings);
|
services.AddScoped<ISettingsStore>(_ => settings.Store);
|
||||||
},
|
},
|
||||||
async channel =>
|
async channel =>
|
||||||
{
|
{
|
||||||
@@ -138,12 +139,12 @@ public sealed class TelegramIngressServiceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task ReportStatus_UnknownTenant_NotSavedWithoutRpcError()
|
public async Task ReportStatus_UnknownTenant_NotSavedWithoutRpcError()
|
||||||
{
|
{
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
var registry = TestRegistry.With(Tenant(TenantA));
|
var registry = TestRegistry.With(Tenant(TenantA));
|
||||||
|
|
||||||
await RunAsync(
|
await RunAsync(
|
||||||
registry,
|
registry,
|
||||||
services => services.AddScoped<ISettingsStore>(_ => settings),
|
services => services.AddScoped<ISettingsStore>(_ => settings.Store),
|
||||||
async channel =>
|
async channel =>
|
||||||
{
|
{
|
||||||
var client = new IngressService.IngressServiceClient(channel);
|
var client = new IngressService.IngressServiceClient(channel);
|
||||||
@@ -261,7 +262,7 @@ public sealed class TelegramIngressServiceTests
|
|||||||
// phase: Ожидаемая фаза.
|
// phase: Ожидаемая фаза.
|
||||||
// connected: Ожидаемый флаг connected.
|
// connected: Ожидаемый флаг connected.
|
||||||
private static void AssertStatusStored(
|
private static void AssertStatusStored(
|
||||||
FakeSettingsStore settings,
|
TestSettingsStore settings,
|
||||||
string phase,
|
string phase,
|
||||||
bool connected)
|
bool connected)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -99,7 +99,7 @@ internal static class TelegramIngressTestHost
|
|||||||
.AddCheck("ready", () => HealthCheckResult.Healthy("хост готов"));
|
.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<ITelegramStore>(_ => new TestTelegramStore().Store);
|
||||||
builder.Services.AddScoped<ITelegramGateway>(_ => new TestTelegramGateway().Gateway);
|
builder.Services.AddScoped<ITelegramGateway>(_ => new TestTelegramGateway().Gateway);
|
||||||
builder.Services.AddTelegramModule();
|
builder.Services.AddTelegramModule();
|
||||||
|
|||||||
@@ -71,7 +71,7 @@ public sealed class TelegramSourceIngestObserverTests
|
|||||||
var store = new TestTelegramStore();
|
var store = new TestTelegramStore();
|
||||||
var dialogs = new DialogsService(
|
var dialogs = new DialogsService(
|
||||||
store.Store,
|
store.Store,
|
||||||
new FakeSettingsStore(),
|
new TestSettingsStore().Store,
|
||||||
new TestTelegramGateway().Gateway,
|
new TestTelegramGateway().Gateway,
|
||||||
NullLogger<DialogsService>.Instance);
|
NullLogger<DialogsService>.Instance);
|
||||||
var observer = new TelegramSourceIngestObserver(dialogs, NullLogger<TelegramSourceIngestObserver>.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.Cards.Application.Sources;
|
||||||
using Deal.Modules.Kanban.Application.Abstractions;
|
using Deal.Modules.Kanban.Application.Abstractions;
|
||||||
using Deal.Modules.Kanban.Application.Models;
|
using Deal.Modules.Kanban.Application.Models;
|
||||||
|
using NSubstitute;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Modules.Cards;
|
namespace Deal.Tests.Unit.Support;
|
||||||
|
|
||||||
/// <summary>
|
/// <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>
|
/// </summary>
|
||||||
public class FakeKanjStore : ICardStore
|
public sealed class TestKanjStore
|
||||||
{
|
{
|
||||||
private readonly List<ContainerDto> _boards = [];
|
private readonly List<ContainerDto> _boards = [];
|
||||||
private readonly List<CardDto> _cards = [];
|
private readonly List<CardDto> _cards = [];
|
||||||
private readonly List<CardMoveDto> _moves = [];
|
private readonly List<CardMoveDto> _moves = [];
|
||||||
private readonly List<(string Query, int Limit)> _searchCalls = [];
|
private readonly List<(string Query, int Limit)> _searchCalls = [];
|
||||||
|
|
||||||
private readonly HashSet<string> _firedById = new(StringComparer.Ordinal);
|
private readonly HashSet<string> _firedById = new(StringComparer.Ordinal);
|
||||||
|
|
||||||
private readonly Dictionary<string, DateTimeOffset> _archivedAtById = new(StringComparer.Ordinal);
|
private readonly Dictionary<string, DateTimeOffset> _archivedAtById = new(StringComparer.Ordinal);
|
||||||
|
private readonly bool _throwOnDueReminders;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Доски фейка (копия на момент обращения) — проверки BoardsServiceTests и LocalColumnSuggesterTests.
|
/// Подставка порта единого домена карточки (создаётся в конструкторе).
|
||||||
|
/// </summary>
|
||||||
|
public ICardStore Store { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Доски хранилища (копия на момент обращения).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<ContainerDto> Boards => _boards.ToList();
|
public IReadOnlyList<ContainerDto> Boards => _boards.ToList();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Карточки фейка как тройки
|
/// Карточки хранилища как тройки (id, колонка, флаг «новая»).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<(string CardId, string Col, bool IsNew)> Cards =>
|
public IReadOnlyList<(string CardId, string Col, bool IsNew)> Cards =>
|
||||||
_cards.Select(card => (card.Id, card.Col, card.IsNew)).ToList();
|
_cards.Select(card => (card.Id, card.Col, card.IsNew)).ToList();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Полные карточки фейка
|
/// Полные карточки хранилища (копия на момент обращения).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<CardDto> CardDtos => _cards.ToList();
|
public IReadOnlyList<CardDto> CardDtos => _cards.ToList();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Флаг «сбой записи карточки»
|
/// Флаг «сбой записи карточки».
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool FailAddCard { get; set; }
|
public bool FailAddCard { get; set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Записи журнала CardMoves
|
/// Записи журнала CardMoves.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<CardMoveDto> Moves => _moves.ToList();
|
public IReadOnlyList<CardMoveDto> Moves => _moves.ToList();
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Вызовы SearchCardsAsync как пары
|
/// Вызовы SearchCardsAsync как пары (запрос, лимит).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<(string Query, int Limit)> SearchCalls => _searchCalls.ToList();
|
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>
|
||||||
/// Кладёт доску напрямую
|
/// Кладёт доску напрямую
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="board">Доска как если бы была сохранена в БД.</param>
|
/// <param name="board">Доска как если бы была сохранена в БД.</param>
|
||||||
public void SeedBoard(ContainerDto board)
|
public void SeedBoard(ContainerDto board) => _boards.Add(board);
|
||||||
{
|
|
||||||
_boards.Add(board);
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Кладёт карточку в колонку.
|
/// Кладёт карточку в колонку.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cardId">Id карточки.</param>
|
/// <param name="cardId">Id карточки.</param>
|
||||||
/// <param name="col">Колонка (inbox/доска).</param>
|
/// <param name="col">Колонка (inbox/доска).</param>
|
||||||
public void AddCard(string cardId, string col)
|
public void AddCard(string cardId, string col) => SeedCard(new CardDto { Id = cardId, Col = col });
|
||||||
{
|
|
||||||
SeedCard(new CardDto { Id = cardId, Col = col });
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Кладёт полную карточку.
|
/// Кладёт полную карточку.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="card">Карточка как если бы была сохранена в БД (комментарии — приложенным массивом).</param>
|
/// <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)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Контейнеры (колонки/стадии/зоны) ─────────────────────────────────
|
private void ConfigureCards()
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<IReadOnlyList<ContainerDto>> ListContainersAsync(string? space, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
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)
|
.Where(board => space is null || board.Space == space)
|
||||||
.OrderBy(board => board.Suggested)
|
.OrderBy(board => board.Suggested)
|
||||||
.ThenBy(board => board.Order)
|
.ThenBy(board => board.Order)
|
||||||
.ToList();
|
.ToList();
|
||||||
return Task.FromResult(ordered);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private void UpdateContainer(ContainerDto container)
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
int index = _boards.FindIndex(item => item.Id == container.Id);
|
int index = _boards.FindIndex(item => item.Id == container.Id);
|
||||||
_boards[index] = container;
|
_boards[index] = container;
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private int DeleteContainer(string containerId)
|
||||||
public Task<int> DeleteContainerAsync(string containerId, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
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++)
|
for (int i = 0; i < _cards.Count; i++)
|
||||||
{
|
{
|
||||||
if (_cards[i].Col == containerId)
|
if (_cards[i].Col == containerId)
|
||||||
@@ -125,54 +267,39 @@ public class FakeKanjStore : ICardStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
_boards.RemoveAll(board => board.Id == containerId);
|
_boards.RemoveAll(board => board.Id == containerId);
|
||||||
return Task.FromResult(moved.Count);
|
return moved;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private void ReorderContainers(string space, IReadOnlyList<string> containerIds)
|
||||||
public Task ReorderContainersAsync(
|
|
||||||
string space,
|
|
||||||
IReadOnlyList<string> containerIds,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
for (int i = 0; i < containerIds.Count; i++)
|
for (int i = 0; i < containerIds.Count; i++)
|
||||||
{
|
{
|
||||||
string containerId = containerIds[i];
|
int index = _boards.FindIndex(board => board.Id == containerIds[i] && board.Space == space);
|
||||||
int index = _boards.FindIndex(board => board.Id == containerId && board.Space == space);
|
|
||||||
if (index >= 0)
|
if (index >= 0)
|
||||||
{
|
{
|
||||||
_boards[index] = _boards[index] with { Order = i };
|
_boards[index] = _boards[index] with { Order = i };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Карточки ─────────────────────────────────────────────────────────
|
private IReadOnlyList<CardDto> ListCards(CardsQuery query)
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<IReadOnlyList<CardDto>> ListCardsAsync(CardsQuery query, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
IEnumerable<CardDto> result = query.Col is null
|
IEnumerable<CardDto> result = query.Col is null
|
||||||
? _cards
|
? _cards
|
||||||
: _cards.Where(card => card.Col == query.Col);
|
: _cards.Where(card => card.Col == query.Col);
|
||||||
return Task.FromResult<IReadOnlyList<CardDto>>(
|
return result.OrderByDescending(card => card.ReceivedAtMs).ToList();
|
||||||
result.OrderByDescending(card => card.ReceivedAtMs).ToList());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private IReadOnlyList<CardDto> SearchCards(string q, int limit)
|
||||||
public Task<IReadOnlyList<CardDto>> SearchCardsAsync(
|
|
||||||
string q,
|
|
||||||
int limit,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
_searchCalls.Add((q, limit));
|
_searchCalls.Add((q, limit));
|
||||||
string lowered = q.Trim().ToLowerInvariant();
|
string lowered = q.Trim().ToLowerInvariant();
|
||||||
if (lowered.Length == 0)
|
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)
|
.Where(card => card.Title.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal)
|
||||||
|| card.Summary.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal)
|
|| card.Summary.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal)
|
||||||
|| card.Contact.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal)
|
|| card.Contact.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal)
|
||||||
@@ -180,38 +307,26 @@ public class FakeKanjStore : ICardStore
|
|||||||
.OrderByDescending(card => card.ReceivedAtMs)
|
.OrderByDescending(card => card.ReceivedAtMs)
|
||||||
.Take(limit)
|
.Take(limit)
|
||||||
.ToList();
|
.ToList();
|
||||||
return Task.FromResult<IReadOnlyList<CardDto>>(result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private CardDto? GetCardBySource(SourceRef source)
|
||||||
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)
|
|
||||||
{
|
{
|
||||||
if (string.IsNullOrEmpty(source.Kind))
|
if (string.IsNullOrEmpty(source.Kind))
|
||||||
{
|
{
|
||||||
return Task.FromResult<CardDto?>(null);
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
string externalId = source.ExternalId ?? string.Empty;
|
string externalId = source.ExternalId ?? string.Empty;
|
||||||
string originRef = source.OriginRef ?? string.Empty;
|
string originRef = source.OriginRef ?? string.Empty;
|
||||||
CardDto? match = _cards
|
return _cards
|
||||||
.Where(card => card.Source.Kind == source.Kind
|
.Where(card => card.Source.Kind == source.Kind
|
||||||
&& (card.Source.ExternalId ?? string.Empty) == externalId
|
&& (card.Source.ExternalId ?? string.Empty) == externalId
|
||||||
&& (card.Source.OriginRef ?? string.Empty) == originRef)
|
&& (card.Source.OriginRef ?? string.Empty) == originRef)
|
||||||
.OrderByDescending(card => card.ReceivedAtMs)
|
.OrderByDescending(card => card.ReceivedAtMs)
|
||||||
.FirstOrDefault();
|
.FirstOrDefault();
|
||||||
return Task.FromResult(match);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private void AddCard(CardSnapshot snapshot)
|
||||||
public Task AddCardAsync(CardSnapshot snapshot, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
if (FailAddCard)
|
if (FailAddCard)
|
||||||
{
|
{
|
||||||
@@ -249,16 +364,14 @@ public class FakeKanjStore : ICardStore
|
|||||||
CreatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
CreatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
});
|
});
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private void UpdateColumn(CardColumnUpdateDto update)
|
||||||
public Task UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
int index = _cards.FindIndex(card => card.Id == update.CardId);
|
int index = _cards.FindIndex(card => card.Id == update.CardId);
|
||||||
if (index < 0)
|
if (index < 0)
|
||||||
{
|
{
|
||||||
return Task.CompletedTask;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_cards[index] = _cards[index] with
|
_cards[index] = _cards[index] with
|
||||||
@@ -276,17 +389,14 @@ public class FakeKanjStore : ICardStore
|
|||||||
{
|
{
|
||||||
_archivedAtById.Remove(update.CardId);
|
_archivedAtById.Remove(update.CardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private bool ApplyReclassification(CardReclassificationDto update)
|
||||||
public Task<bool> ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
int index = _cards.FindIndex(card => card.Id == update.CardId);
|
int index = _cards.FindIndex(card => card.Id == update.CardId);
|
||||||
if (index < 0)
|
if (index < 0)
|
||||||
{
|
{
|
||||||
return Task.FromResult(false);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_cards[index] = _cards[index] with
|
_cards[index] = _cards[index] with
|
||||||
@@ -304,40 +414,28 @@ public class FakeKanjStore : ICardStore
|
|||||||
Contacts = update.Contacts,
|
Contacts = update.Contacts,
|
||||||
MatchHits = update.MatchHits,
|
MatchHits = update.MatchHits,
|
||||||
};
|
};
|
||||||
return Task.FromResult(true);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private void UpdateSeen(string? cardId, string? col)
|
||||||
public Task UpdateSeenAsync(
|
|
||||||
string? cardId,
|
|
||||||
string? col,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
for (int i = 0; i < _cards.Count; i++)
|
for (int i = 0; i < _cards.Count; i++)
|
||||||
{
|
{
|
||||||
bool matches = cardId is not null
|
bool matches = cardId is not null ? _cards[i].Id == cardId : col is null || _cards[i].Col == col;
|
||||||
? _cards[i].Id == cardId
|
|
||||||
: col is null || _cards[i].Col == col;
|
|
||||||
if (matches && _cards[i].IsNew)
|
if (matches && _cards[i].IsNew)
|
||||||
{
|
{
|
||||||
_cards[i] = _cards[i] with { IsNew = false };
|
_cards[i] = _cards[i] with { IsNew = false };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private void DeleteForever(string cardId)
|
||||||
public Task DeleteForeverAsync(string cardId, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
// Удаляется карточка (с комментариями — они часть карточки в фейке); журнал CardMoves не трогаем.
|
|
||||||
_cards.RemoveAll(card => card.Id == cardId);
|
_cards.RemoveAll(card => card.Id == cardId);
|
||||||
_archivedAtById.Remove(cardId);
|
_archivedAtById.Remove(cardId);
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private int ClearCol(string col)
|
||||||
public Task<int> ClearColAsync(string col, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
List<string> removedIds = _cards.Where(card => card.Col == col).Select(card => card.Id).ToList();
|
List<string> removedIds = _cards.Where(card => card.Col == col).Select(card => card.Id).ToList();
|
||||||
_cards.RemoveAll(card => card.Col == col);
|
_cards.RemoveAll(card => card.Col == col);
|
||||||
@@ -346,64 +444,33 @@ public class FakeKanjStore : ICardStore
|
|||||||
_archivedAtById.Remove(cardId);
|
_archivedAtById.Remove(cardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult(removedIds.Count);
|
return removedIds.Count;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private IReadOnlyDictionary<string, CardColumnCountDto> CountCardsByCol()
|
||||||
public Task<IReadOnlyDictionary<string, CardColumnCountDto>> CountCardsByColAsync(CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
IReadOnlyDictionary<string, CardColumnCountDto> result = _cards
|
return _cards
|
||||||
.GroupBy(card => card.Col)
|
.GroupBy(card => card.Col)
|
||||||
.ToDictionary(
|
.ToDictionary(
|
||||||
group => group.Key,
|
group => group.Key,
|
||||||
group => new CardColumnCountDto(group.Count(), group.Count(card => card.IsNew)));
|
group => new CardColumnCountDto(group.Count(), group.Count(card => card.IsNew)));
|
||||||
return Task.FromResult(result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Комментарии (LeadComments) ────────────────────────────────────────
|
private void AddComment(string commentId, string cardId, string by, string text)
|
||||||
|
|
||||||
/// <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)
|
|
||||||
{
|
{
|
||||||
int index = _cards.FindIndex(card => card.Id == cardId);
|
int index = _cards.FindIndex(card => card.Id == cardId);
|
||||||
if (index < 0)
|
if (index < 0)
|
||||||
{
|
{
|
||||||
return Task.CompletedTask;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Метка времени свежего комментария — «только что» (адаптер считает HumanAge от CreatedAt = UtcNow).
|
// Метка времени свежего комментария — «только что» (адаптер считает HumanAge от CreatedAt = UtcNow).
|
||||||
CardCommentDto comment = new(commentId, by, text, "только что");
|
CardCommentDto comment = new(commentId, by, text, "только что");
|
||||||
IReadOnlyList<CardCommentDto> updated = _cards[index].Comments.Concat(new[] { comment }).ToList();
|
IReadOnlyList<CardCommentDto> updated = _cards[index].Comments.Concat(new[] { comment }).ToList();
|
||||||
_cards[index] = _cards[index] with { Comments = updated };
|
_cards[index] = _cards[index] with { Comments = updated };
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Журнал CardMoves (learning_log) ───────────────────────────────────
|
private IReadOnlyList<AiMarkupExampleDto> GetAiMarkupExamples(int limit)
|
||||||
|
|
||||||
/// <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)
|
|
||||||
{
|
{
|
||||||
var examples = new List<AiMarkupExampleDto>();
|
var examples = new List<AiMarkupExampleDto>();
|
||||||
for (int index = _moves.Count - 1; index >= 0 && examples.Count < limit; index--)
|
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));
|
examples.Add(new AiMarkupExampleDto(sourceText, move.ToCol));
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult<IReadOnlyList<AiMarkupExampleDto>>(examples);
|
return examples;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<string> ListArchiveCandidates(DateTimeOffset receivedBeforeUtc)
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<IReadOnlyList<string>> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
var boardIds = new HashSet<string>(_boards.Select(board => board.Id), StringComparer.Ordinal);
|
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))
|
.Where(card => (card.Col == KanbanColumns.Inbox || boardIds.Contains(card.Col))
|
||||||
&& ReceivedAtOf(card) < receivedBeforeUtc)
|
&& ReceivedAtOf(card) < receivedBeforeUtc)
|
||||||
.Select(card => card.Id)
|
.Select(card => card.Id)
|
||||||
.ToList();
|
.ToList();
|
||||||
return Task.FromResult(result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private int Archive(IReadOnlyList<string> cardIds, DateTimeOffset archivedAt)
|
||||||
public Task<int> ArchiveAsync(
|
|
||||||
IReadOnlyList<string> cardIds,
|
|
||||||
DateTimeOffset archivedAt,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
if (cardIds.Count == 0)
|
if (cardIds.Count == 0)
|
||||||
{
|
{
|
||||||
return Task.FromResult(0);
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
var ids = new HashSet<string>(cardIds, StringComparer.Ordinal);
|
var ids = new HashSet<string>(cardIds, StringComparer.Ordinal);
|
||||||
@@ -475,38 +535,32 @@ public class FakeKanjStore : ICardStore
|
|||||||
archived++;
|
archived++;
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult(archived);
|
return archived;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private IReadOnlyList<string> ListExpiredArchiveCandidates(DateTimeOffset archivedBeforeUtc)
|
||||||
public Task<IReadOnlyList<string>> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
IReadOnlyList<string> result = _cards
|
return _cards
|
||||||
.Where(card => card.Col == KanbanColumns.Archive
|
.Where(card => card.Col == KanbanColumns.Archive
|
||||||
&& _archivedAtById.TryGetValue(card.Id, out DateTimeOffset archivedAt)
|
&& _archivedAtById.TryGetValue(card.Id, out DateTimeOffset archivedAt)
|
||||||
&& archivedAt < archivedBeforeUtc)
|
&& archivedAt < archivedBeforeUtc)
|
||||||
.Select(card => card.Id)
|
.Select(card => card.Id)
|
||||||
.ToList();
|
.ToList();
|
||||||
return Task.FromResult(result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private IReadOnlyList<string> ListTrashCandidates(DateTimeOffset receivedBeforeUtc)
|
||||||
public Task<IReadOnlyList<string>> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
IReadOnlyList<string> result = _cards
|
return _cards
|
||||||
.Where(card => card.Col == KanbanColumns.Trash && ReceivedAtOf(card) < receivedBeforeUtc)
|
.Where(card => card.Col == KanbanColumns.Trash && ReceivedAtOf(card) < receivedBeforeUtc)
|
||||||
.Select(card => card.Id)
|
.Select(card => card.Id)
|
||||||
.ToList();
|
.ToList();
|
||||||
return Task.FromResult(result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private int Purge(IReadOnlyList<string> cardIds)
|
||||||
public Task<int> PurgeAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
// Жёсткое удаление пачки (как KanbanStore.PurgeAsync): комментарии — часть карточки, журнал не трогаем.
|
|
||||||
if (cardIds.Count == 0)
|
if (cardIds.Count == 0)
|
||||||
{
|
{
|
||||||
return Task.FromResult(0);
|
return 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
var ids = new HashSet<string>(cardIds, StringComparer.Ordinal);
|
var ids = new HashSet<string>(cardIds, StringComparer.Ordinal);
|
||||||
@@ -516,88 +570,45 @@ public class FakeKanjStore : ICardStore
|
|||||||
_archivedAtById.Remove(cardId);
|
_archivedAtById.Remove(cardId);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult(removed);
|
return removed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
private IReadOnlyList<CardDto> ListCardsForConversion()
|
||||||
/// Задаёт метку архивации карточки
|
|
||||||
/// </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)
|
|
||||||
{
|
{
|
||||||
return _archivedAtById.TryGetValue(cardId, out DateTimeOffset archivedAt) ? archivedAt : null;
|
return _cards
|
||||||
}
|
|
||||||
|
|
||||||
// Момент получения карточки из её 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
|
|
||||||
.Where(card => card.Budget is not null
|
.Where(card => card.Budget is not null
|
||||||
&& card.Col != KanbanColumns.Archive
|
&& card.Col != KanbanColumns.Archive
|
||||||
&& card.Col != KanbanColumns.Trash)
|
&& card.Col != KanbanColumns.Trash)
|
||||||
.OrderByDescending(card => card.ReceivedAtMs)
|
.OrderByDescending(card => card.ReceivedAtMs)
|
||||||
.ToList();
|
.ToList();
|
||||||
return Task.FromResult(result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private void UpdateConversion(string cardId, double? convFrom, double? convTo, string convCur)
|
||||||
public Task UpdateConversionAsync(
|
|
||||||
string cardId,
|
|
||||||
double? convFrom,
|
|
||||||
double? convTo,
|
|
||||||
string convCur,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
int index = _cards.FindIndex(card => card.Id == cardId);
|
int index = _cards.FindIndex(card => card.Id == cardId);
|
||||||
if (index >= 0)
|
if (index >= 0)
|
||||||
{
|
{
|
||||||
_cards[index] = _cards[index] with
|
_cards[index] = _cards[index] with
|
||||||
{
|
{
|
||||||
Converted = convCur.Length == 0
|
Converted = convCur.Length == 0 ? null : new CardBudgetDto(convFrom, convTo, convCur),
|
||||||
? null
|
|
||||||
: new CardBudgetDto(convFrom, convTo, convCur),
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private IReadOnlyList<CardDto> ListSelectedCards(string? containerId)
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<IReadOnlyList<CardDto>> ListSelectedCardsAsync(string? containerId, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
IEnumerable<CardDto> query = containerId is null
|
IEnumerable<CardDto> query = containerId is null
|
||||||
? _cards.Where(card => CardsDefaultContainers.Contains(card.Col))
|
? _cards.Where(card => CardsDefaultContainers.Contains(card.Col))
|
||||||
: _cards.Where(card => card.Col == containerId);
|
: _cards.Where(card => card.Col == containerId);
|
||||||
return Task.FromResult<IReadOnlyList<CardDto>>(
|
return query.OrderByDescending(card => card.UpdatedAtMs).ToList();
|
||||||
query.OrderByDescending(card => card.UpdatedAtMs).ToList());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private bool PatchCard(string cardId, CardPatch patch)
|
||||||
public Task<bool> PatchCardAsync(
|
|
||||||
string cardId,
|
|
||||||
CardPatch patch,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
int index = _cards.FindIndex(card => card.Id == cardId);
|
int index = _cards.FindIndex(card => card.Id == cardId);
|
||||||
if (index < 0)
|
if (index < 0)
|
||||||
{
|
{
|
||||||
return Task.FromResult(false);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
CardDto card = _cards[index];
|
CardDto card = _cards[index];
|
||||||
@@ -647,19 +658,15 @@ public class FakeKanjStore : ICardStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
_cards[index] = card with { UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() };
|
_cards[index] = card with { UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() };
|
||||||
return Task.FromResult(true);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private bool AddLink(string cardId, CardLinkDto link)
|
||||||
public Task<bool> AddLinkAsync(
|
|
||||||
string cardId,
|
|
||||||
CardLinkDto link,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
int index = _cards.FindIndex(card => card.Id == cardId);
|
int index = _cards.FindIndex(card => card.Id == cardId);
|
||||||
if (index < 0)
|
if (index < 0)
|
||||||
{
|
{
|
||||||
return Task.FromResult(false);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_cards[index] = _cards[index] with
|
_cards[index] = _cards[index] with
|
||||||
@@ -667,19 +674,15 @@ public class FakeKanjStore : ICardStore
|
|||||||
Links = [.. _cards[index].Links, link],
|
Links = [.. _cards[index].Links, link],
|
||||||
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
};
|
};
|
||||||
return Task.FromResult(true);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private bool RemoveLink(string cardId, string linkId)
|
||||||
public Task<bool> RemoveLinkAsync(
|
|
||||||
string cardId,
|
|
||||||
string linkId,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
int index = _cards.FindIndex(card => card.Id == cardId);
|
int index = _cards.FindIndex(card => card.Id == cardId);
|
||||||
if (index < 0)
|
if (index < 0)
|
||||||
{
|
{
|
||||||
return Task.FromResult(false);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_cards[index] = _cards[index] with
|
_cards[index] = _cards[index] with
|
||||||
@@ -687,19 +690,15 @@ public class FakeKanjStore : ICardStore
|
|||||||
Links = _cards[index].Links.Where(link => link.Id != linkId).ToList(),
|
Links = _cards[index].Links.Where(link => link.Id != linkId).ToList(),
|
||||||
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
};
|
};
|
||||||
return Task.FromResult(true);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private bool AddFile(string cardId, CardFileDto file)
|
||||||
public Task<bool> AddFileAsync(
|
|
||||||
string cardId,
|
|
||||||
CardFileDto file,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
int index = _cards.FindIndex(card => card.Id == cardId);
|
int index = _cards.FindIndex(card => card.Id == cardId);
|
||||||
if (index < 0)
|
if (index < 0)
|
||||||
{
|
{
|
||||||
return Task.FromResult(false);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_cards[index] = _cards[index] with
|
_cards[index] = _cards[index] with
|
||||||
@@ -707,19 +706,15 @@ public class FakeKanjStore : ICardStore
|
|||||||
Files = [.. _cards[index].Files, file],
|
Files = [.. _cards[index].Files, file],
|
||||||
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
};
|
};
|
||||||
return Task.FromResult(true);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private bool RemoveFile(string cardId, string fileId)
|
||||||
public Task<bool> RemoveFileAsync(
|
|
||||||
string cardId,
|
|
||||||
string fileId,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
int index = _cards.FindIndex(card => card.Id == cardId);
|
int index = _cards.FindIndex(card => card.Id == cardId);
|
||||||
if (index < 0)
|
if (index < 0)
|
||||||
{
|
{
|
||||||
return Task.FromResult(false);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
_cards[index] = _cards[index] with
|
_cards[index] = _cards[index] with
|
||||||
@@ -727,21 +722,15 @@ public class FakeKanjStore : ICardStore
|
|||||||
Files = _cards[index].Files.Where(file => file.Id != fileId).ToList(),
|
Files = _cards[index].Files.Where(file => file.Id != fileId).ToList(),
|
||||||
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
};
|
};
|
||||||
return Task.FromResult(true);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private bool MoveCardStage(string cardId, string containerId, CardHistoryDto historyEntry, long atMs)
|
||||||
public Task<bool> MoveCardStageAsync(
|
|
||||||
string cardId,
|
|
||||||
string containerId,
|
|
||||||
CardHistoryDto historyEntry,
|
|
||||||
long atMs,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
int index = _cards.FindIndex(card => card.Id == cardId);
|
int index = _cards.FindIndex(card => card.Id == cardId);
|
||||||
if (index < 0)
|
if (index < 0)
|
||||||
{
|
{
|
||||||
return Task.FromResult(false);
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
CardDto card = _cards[index];
|
CardDto card = _cards[index];
|
||||||
@@ -753,19 +742,15 @@ public class FakeKanjStore : ICardStore
|
|||||||
UpdatedAtMs = atMs,
|
UpdatedAtMs = atMs,
|
||||||
History = [.. card.History, historyEntry],
|
History = [.. card.History, historyEntry],
|
||||||
};
|
};
|
||||||
return Task.FromResult(true);
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private void SetReminder(string cardId, long atMs)
|
||||||
public Task SetReminderAsync(
|
|
||||||
string cardId,
|
|
||||||
long atMs,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
int index = _cards.FindIndex(card => card.Id == cardId);
|
int index = _cards.FindIndex(card => card.Id == cardId);
|
||||||
if (index < 0)
|
if (index < 0)
|
||||||
{
|
{
|
||||||
return Task.CompletedTask;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
_firedById.Remove(cardId);
|
_firedById.Remove(cardId);
|
||||||
@@ -774,11 +759,9 @@ public class FakeKanjStore : ICardStore
|
|||||||
Reminder = new CardReminderDto(atMs),
|
Reminder = new CardReminderDto(atMs),
|
||||||
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
|
||||||
};
|
};
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private void ClearReminder(string cardId)
|
||||||
public Task ClearReminderAsync(string cardId, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
int index = _cards.FindIndex(card => card.Id == cardId);
|
int index = _cards.FindIndex(card => card.Id == cardId);
|
||||||
if (index >= 0)
|
if (index >= 0)
|
||||||
@@ -787,20 +770,14 @@ public class FakeKanjStore : ICardStore
|
|||||||
}
|
}
|
||||||
|
|
||||||
_firedById.Remove(cardId);
|
_firedById.Remove(cardId);
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private int ClearStage(string containerId) => _cards.RemoveAll(card => card.Col == containerId);
|
||||||
public Task<int> ClearStageAsync(string containerId, CancellationToken ct)
|
|
||||||
{
|
|
||||||
return Task.FromResult(_cards.RemoveAll(card => card.Col == containerId));
|
|
||||||
}
|
|
||||||
|
|
||||||
/// <inheritdoc />
|
private IReadOnlyList<CardReminderDueDto> ListDueReminders(DateTimeOffset now)
|
||||||
public virtual Task<IReadOnlyList<CardReminderDueDto>> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
long nowMs = now.ToUnixTimeMilliseconds();
|
long nowMs = now.ToUnixTimeMilliseconds();
|
||||||
IReadOnlyList<CardReminderDueDto> due = _cards
|
return _cards
|
||||||
.Where(card => card.Col == CardsDefaultContainers.Hold
|
.Where(card => card.Col == CardsDefaultContainers.Hold
|
||||||
&& card.Reminder is not null
|
&& card.Reminder is not null
|
||||||
&& !_firedById.Contains(card.Id)
|
&& !_firedById.Contains(card.Id)
|
||||||
@@ -808,11 +785,9 @@ public class FakeKanjStore : ICardStore
|
|||||||
.OrderBy(card => card.Reminder!.At)
|
.OrderBy(card => card.Reminder!.At)
|
||||||
.Select(card => new CardReminderDueDto(card.Id, card.Title, card.Col))
|
.Select(card => new CardReminderDueDto(card.Id, card.Title, card.Col))
|
||||||
.ToList();
|
.ToList();
|
||||||
return Task.FromResult(due);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private void MarkRemindersFired(IReadOnlyList<string> cardIds)
|
||||||
public Task MarkRemindersFiredAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
foreach (string cardId in cardIds)
|
foreach (string cardId in cardIds)
|
||||||
{
|
{
|
||||||
@@ -821,12 +796,9 @@ public class FakeKanjStore : ICardStore
|
|||||||
_firedById.Add(cardId);
|
_firedById.Add(cardId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.CompletedTask;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private int ClearExpiredReminders(DateTimeOffset now)
|
||||||
public Task<int> ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
long nowMs = now.ToUnixTimeMilliseconds();
|
long nowMs = now.ToUnixTimeMilliseconds();
|
||||||
int cleared = 0;
|
int cleared = 0;
|
||||||
@@ -840,16 +812,16 @@ public class FakeKanjStore : ICardStore
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Task.FromResult(cleared);
|
return cleared;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
private IReadOnlyList<CardDto> ListInboxWithSource()
|
||||||
public Task<IReadOnlyList<CardDto>> ListInboxWithSourceAsync(CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
IReadOnlyList<CardDto> result = _cards
|
return _cards
|
||||||
.Where(card => card.Col == KanbanColumns.Inbox && (card.Content.Text ?? string.Empty) != string.Empty)
|
.Where(card => card.Col == KanbanColumns.Inbox && (card.Content.Text ?? string.Empty) != string.Empty)
|
||||||
.OrderByDescending(card => card.ReceivedAtMs)
|
.OrderByDescending(card => card.ReceivedAtMs)
|
||||||
.ToList();
|
.ToList();
|
||||||
return Task.FromResult(result);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static DateTimeOffset ReceivedAtOf(CardDto card) => DateTimeOffset.FromUnixTimeMilliseconds(card.ReceivedAtMs);
|
||||||
}
|
}
|
||||||
+26
-26
@@ -1,15 +1,22 @@
|
|||||||
using Deal.Contracts.Integrations.Abstractions;
|
using Deal.Contracts.Integrations.Abstractions;
|
||||||
using Deal.Contracts.Integrations.Models;
|
using Deal.Contracts.Integrations.Models;
|
||||||
|
using NSubstitute;
|
||||||
|
|
||||||
namespace Deal.Tests.Unit.Contracts;
|
namespace Deal.Tests.Unit.Support;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// In-memory реализация <see cref="IMlClient"/> для unit-тестов CardsService и воркера pump.
|
/// Подставка <see cref="IMlClient"/>: сервисы получают NSubstitute-подставку (<see cref="Client"/>),
|
||||||
|
/// тесты управляют сценариями через свойства (как раньше — через фейк).
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class FakeMlClient : IMlClient
|
public sealed class TestMlClient
|
||||||
{
|
{
|
||||||
private readonly List<(string Text, string Label, double Delta)> _pushed = [];
|
private readonly List<(string Text, string Label, double Delta)> _pushed = [];
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Подставка порта ML-клиента (создаётся в конструкторе).
|
||||||
|
/// </summary>
|
||||||
|
public IMlClient Client { get; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Ответ StatusAsync
|
/// Ответ StatusAsync
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -26,37 +33,30 @@ public sealed class FakeMlClient : IMlClient
|
|||||||
public int PredictCalls { get; private set; }
|
public int PredictCalls { get; private set; }
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Обучающие сигналы, отправленные через <see cref="PushAsync"/>
|
/// Обучающие сигналы, отправленные через <see cref="IMlClient.PushAsync"/>
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public IReadOnlyList<(string Text, string Label, double Delta)> Pushed => _pushed.ToList();
|
public IReadOnlyList<(string Text, string Label, double Delta)> Pushed => _pushed.ToList();
|
||||||
|
|
||||||
/// <inheritdoc />
|
/// <summary>
|
||||||
public Task<MlStatusResponseDto> StatusAsync(CancellationToken ct) => Task.FromResult(Status);
|
/// Создаёт подставку с поведением по умолчанию.
|
||||||
|
/// </summary>
|
||||||
/// <inheritdoc />
|
public TestMlClient()
|
||||||
public Task PushAsync(
|
|
||||||
string text,
|
|
||||||
string label,
|
|
||||||
double delta,
|
|
||||||
CancellationToken ct)
|
|
||||||
{
|
{
|
||||||
_pushed.Add((text, label, delta));
|
Client = Substitute.For<IMlClient>();
|
||||||
return Task.CompletedTask;
|
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>()))
|
||||||
/// <inheritdoc />
|
.Do(ci => _pushed.Add((ci.ArgAt<string>(0), ci.ArgAt<string>(1), ci.ArgAt<double>(2))));
|
||||||
public Task<MlPredictResultDto> PredictAsync(string text, CancellationToken ct)
|
Client.PredictAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
|
.Returns<MlPredictResultDto>(ci =>
|
||||||
{
|
{
|
||||||
PredictCalls++;
|
PredictCalls++;
|
||||||
return Predict is null
|
return Predict ?? throw new NotSupportedException();
|
||||||
? throw new NotSupportedException()
|
});
|
||||||
: Task.FromResult(Predict);
|
Client.ResetAsync(Arg.Any<CancellationToken>())
|
||||||
|
.Returns<MlResetResultDto>(_ => throw new NotSupportedException());
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <inheritdoc />
|
|
||||||
public Task<MlResetResultDto> ResetAsync(CancellationToken ct) =>
|
|
||||||
throw new NotSupportedException();
|
|
||||||
|
|
||||||
// Статус по умолчанию: модель не готова, все счётчики 0 (форма MlStatusResponseDto).
|
// Статус по умолчанию: модель не готова, все счётчики 0 (форма MlStatusResponseDto).
|
||||||
// Возвращает: Пустой статус (ml/ai/learning = 0), как у LocalMlClient до действий.
|
// Возвращает: Пустой статус (ml/ai/learning = 0), как у LocalMlClient до действий.
|
||||||
private static MlStatusResponseDto DefaultStatus()
|
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>
|
/// </summary>
|
||||||
public sealed class TokenUsageRecorderTests
|
public sealed class TokenUsageRecorderTests
|
||||||
{
|
{
|
||||||
// Id тенанта сценариев строкой (формат N) — как в GrpcAi* тестах; Guid для FakeTenantLimitStore.
|
// Id тенанта сценариев строкой (формат N) — как в GrpcAi* тестах; Guid для TestTenantLimitStore.
|
||||||
private const string TenantIdValue = "0123456789abcdef0123456789abcdef";
|
private const string TenantIdValue = "0123456789abcdef0123456789abcdef";
|
||||||
|
|
||||||
private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue);
|
private static readonly Guid TenantGuid = Guid.Parse(TenantIdValue);
|
||||||
@@ -26,7 +26,7 @@ public sealed class TokenUsageRecorderTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddAsync_ChargesTenantLimitsAccumulatesLifetimeKvAndWritesEvent()
|
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);
|
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]
|
[Fact]
|
||||||
public async Task AddAsync_AccumulatesAcrossCalls()
|
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 = 500, Completion = 40, Total = 540 }, "deepseek", "m", CancellationToken.None);
|
||||||
await recorder.AddAsync(new Usage { Prompt = 300, Completion = 60, Total = 360 }, "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]
|
[Fact]
|
||||||
public async Task AddAsync_NullUsage_IsNoop()
|
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);
|
await recorder.AddAsync(null, "deepseek", "m", CancellationToken.None);
|
||||||
|
|
||||||
@@ -80,7 +80,7 @@ public sealed class TokenUsageRecorderTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddAsync_ZeroTotal_AccumulatesKvAndWritesEventButSkipsTenantLimits()
|
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 и событие пишутся (вызов состоялся), строка лимита
|
// Ответ модели без оценки (total=0): lifetime-KV и событие пишутся (вызов состоялся), строка лимита
|
||||||
// нулевым расходом не заводится — ленивый GetOrCreate остаётся первому реальному списанию/чтению.
|
// нулевым расходом не заводится — ленивый GetOrCreate остаётся первому реальному списанию/чтению.
|
||||||
@@ -96,11 +96,11 @@ public sealed class TokenUsageRecorderTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddAsync_OutsideTenantContext_Throws()
|
public async Task AddAsync_OutsideTenantContext_Throws()
|
||||||
{
|
{
|
||||||
var settings = new FakeSettingsStore();
|
var settings = new TestSettingsStore();
|
||||||
var limits = new FakeTenantLimitStore();
|
var limits = new TestTenantLimitStore();
|
||||||
ITenantContext tenantContext = new TenantContext(); // без SetTenant — списание вне tenant-контекста невозможно.
|
ITenantContext tenantContext = new TenantContext(); // без SetTenant — списание вне tenant-контекста невозможно.
|
||||||
var recorder = new TokenUsageRecorder(
|
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>(
|
await Assert.ThrowsAsync<InvalidOperationException>(
|
||||||
() => recorder.AddAsync(new Usage { Prompt = 1, Completion = 1, Total = 2 }, "deepseek", "m", CancellationToken.None));
|
() => recorder.AddAsync(new Usage { Prompt = 1, Completion = 1, Total = 2 }, "deepseek", "m", CancellationToken.None));
|
||||||
@@ -111,7 +111,7 @@ public sealed class TokenUsageRecorderTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task AddEstimatedAsync_MlCall_WritesMlEventOnlyWithCharEstimate()
|
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-счётчик не трогаются.
|
// 20 символов → 5 токенов (≈chars/4); бюджет и lifetime AI-счётчик не трогаются.
|
||||||
long estimated = await recorder.AddEstimatedAsync(
|
long estimated = await recorder.AddEstimatedAsync(
|
||||||
@@ -141,14 +141,14 @@ public sealed class TokenUsageRecorderTests
|
|||||||
|
|
||||||
// Создаёт recorder сценария: tenant-контекст + фейки KV/лимитов/истории.
|
// Создаёт recorder сценария: tenant-контекст + фейки KV/лимитов/истории.
|
||||||
// Возвращает: Кортеж (настройки, лимиты, история, recorder).
|
// Возвращает: Кортеж (настройки, лимиты, история, 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 settings = new TestSettingsStore();
|
||||||
var limits = new FakeTenantLimitStore();
|
var limits = new TestTenantLimitStore();
|
||||||
var events = new TestTokenUsageEventStore();
|
var events = new TestTokenUsageEventStore();
|
||||||
ITenantContext tenantContext = new TenantContext();
|
ITenantContext tenantContext = new TenantContext();
|
||||||
tenantContext.SetTenant(new TenantId(TenantIdValue));
|
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);
|
return (settings, limits, events, recorder);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user