Перевести FakePipelineStore на NSubstitute
This commit is contained in:
@@ -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/мин: единственный разрешённый вызов исчерпывает лимит.
|
||||||
|
|||||||
@@ -29,10 +29,10 @@ 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"));
|
||||||
@@ -105,7 +105,7 @@ 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, TestMlLearningStore>(),
|
new Dictionary<string, TestMlLearningStore>(),
|
||||||
tenants: Array.Empty<TenantRecordDto>());
|
tenants: Array.Empty<TenantRecordDto>());
|
||||||
|
|
||||||
@@ -123,8 +123,8 @@ 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, TestMlLearningStore> OutboxStores(params Guid[] tenants)
|
private static Dictionary<string, TestMlLearningStore> OutboxStores(params Guid[] tenants)
|
||||||
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new TestMlLearningStore());
|
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new TestMlLearningStore());
|
||||||
@@ -139,7 +139,7 @@ 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, TestMlLearningStore> outboxByTenant,
|
IReadOnlyDictionary<string, TestMlLearningStore> outboxByTenant,
|
||||||
IReadOnlyList<TenantRecordDto>? tenants = null,
|
IReadOnlyList<TenantRecordDto>? tenants = null,
|
||||||
ITenantLimitStore? limitStore = null)
|
ITenantLimitStore? limitStore = null)
|
||||||
@@ -153,7 +153,7 @@ 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 TestMlClient().Client);
|
services.AddScoped<IMlClient>(_ => new TestMlClient().Client);
|
||||||
services.AddScoped<PipelineIngestService>();
|
services.AddScoped<PipelineIngestService>();
|
||||||
services.AddScoped<PipelineProcessingService>();
|
services.AddScoped<PipelineProcessingService>();
|
||||||
|
|||||||
@@ -18,9 +18,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, FakeKanjStore 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 +38,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, FakeKanjStore 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 +66,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, FakeKanjStore 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 +77,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, FakeKanjStore Store, TestPipelineStore PipelineStore) Create()
|
||||||
{
|
{
|
||||||
var store = new FakeKanjStore();
|
var store = new FakeKanjStore();
|
||||||
var pipelineStore = new FakePipelineStore();
|
var pipelineStore = new TestPipelineStore();
|
||||||
var composer = new CardComposer(store, new TestSettingsStore().Store);
|
var composer = new CardComposer(store, new TestSettingsStore().Store);
|
||||||
return (new PipelineCardWriter(store, pipelineStore, composer), store, pipelineStore);
|
return (new PipelineCardWriter(store, pipelineStore.Store, composer), store, pipelineStore);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Разбор карточки со значениями по умолчанию (сценарий теста перекрывает нужные поля).
|
// Разбор карточки со значениями по умолчанию (сценарий теста перекрывает нужные поля).
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -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, TestMlClient 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, TestMlClient 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, TestMlClient 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, TestMlClient 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, TestMlClient 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, TestMlClient 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, TestMlClient 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, TestMlClient Ml) Create()
|
private static (PipelineProcessingService Service, TestPipelineStore Store, TestMlClient Ml) Create()
|
||||||
{
|
{
|
||||||
FakePipelineStore store = new();
|
TestPipelineStore store = new();
|
||||||
TestMlClient ml = new();
|
TestMlClient ml = new();
|
||||||
PipelineProcessingService service = new(store, ml.Client, new PipelineIngestService(store));
|
PipelineProcessingService service = new(store.Store, ml.Client, new PipelineIngestService(store.Store));
|
||||||
return (service, store, ml);
|
return (service, store, ml);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,10 +41,10 @@ public sealed class PipelineWorkerSchedulerTests
|
|||||||
// Контекст теста: планировщик на общих фейках + каналы подписок тенантов.
|
// Контекст теста: планировщик на общих фейках + каналы подписок тенантов.
|
||||||
private sealed record Context(
|
private sealed record Context(
|
||||||
PipelineWorkerScheduler Scheduler,
|
PipelineWorkerScheduler Scheduler,
|
||||||
FakePipelineStore PipelineA,
|
TestPipelineStore PipelineA,
|
||||||
FakeKanjStore KanjA,
|
FakeKanjStore KanjA,
|
||||||
SseSubscription SubscriptionA,
|
SseSubscription SubscriptionA,
|
||||||
FakePipelineStore PipelineB,
|
TestPipelineStore PipelineB,
|
||||||
FakeKanjStore KanjB,
|
FakeKanjStore KanjB,
|
||||||
SseSubscription SubscriptionB,
|
SseSubscription SubscriptionB,
|
||||||
PipelinePumpGate PumpGate,
|
PipelinePumpGate PumpGate,
|
||||||
@@ -151,8 +151,8 @@ 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 FakeKanjStore();
|
||||||
var kanjB = new FakeKanjStore();
|
var kanjB = new FakeKanjStore();
|
||||||
var settingsA = new TestSettingsStore();
|
var settingsA = new TestSettingsStore();
|
||||||
@@ -174,7 +174,7 @@ public sealed class PipelineWorkerSchedulerTests
|
|||||||
// (эталон StorageTickSchedulerTests/ConnectionStringProvider.ForTenant).
|
// (эталон StorageTickSchedulerTests/ConnectionStringProvider.ForTenant).
|
||||||
services.AddScoped<ICardStore>(provider => TenantOf(provider) == TenantA ? kanjA : kanjB);
|
services.AddScoped<ICardStore>(provider => TenantOf(provider) == TenantA ? kanjA : kanjB);
|
||||||
services.AddScoped<ISettingsStore>(provider => TenantOf(provider) == TenantA ? settingsA.Store : settingsB.Store);
|
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>();
|
||||||
@@ -267,18 +267,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>
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ public sealed class TestPipelineStore
|
|||||||
|
|
||||||
private readonly bool _claimRace;
|
private readonly bool _claimRace;
|
||||||
private readonly bool _claimResult;
|
private readonly bool _claimResult;
|
||||||
|
private readonly bool _throwOnList;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Сколько раз вызван ClaimAsync
|
/// Сколько раз вызван ClaimAsync
|
||||||
@@ -50,17 +51,31 @@ public sealed class TestPipelineStore
|
|||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="claimRace">Сценарий claim-гонки: ExistsAsync=false, ClaimAsync возвращает claimResult.</param>
|
/// <param name="claimRace">Сценарий claim-гонки: ExistsAsync=false, ClaimAsync возвращает claimResult.</param>
|
||||||
/// <param name="claimResult">Результат ClaimAsync в сценарии гонки (false — хэш уже заявлен другим проходом).</param>
|
/// <param name="claimResult">Результат ClaimAsync в сценарии гонки (false — хэш уже заявлен другим проходом).</param>
|
||||||
public TestPipelineStore(bool claimRace = false, bool claimResult = false)
|
/// <param name="throwOnList">Сценарий сбоя чтения очереди: ListAsync бросает (имитация недоступной схемы/БД).</param>
|
||||||
|
public TestPipelineStore(
|
||||||
|
bool claimRace = false,
|
||||||
|
bool claimResult = false,
|
||||||
|
bool throwOnList = false)
|
||||||
{
|
{
|
||||||
_claimRace = claimRace;
|
_claimRace = claimRace;
|
||||||
_claimResult = claimResult;
|
_claimResult = claimResult;
|
||||||
|
_throwOnList = throwOnList;
|
||||||
Store = Substitute.For<IPipelineStore>();
|
Store = Substitute.For<IPipelineStore>();
|
||||||
Store.ExistsDuplicateAsync(Arg.Any<SourceRef>(), Arg.Any<CancellationToken>())
|
Store.ExistsDuplicateAsync(Arg.Any<SourceRef>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(ci => ExistsDuplicate(ci.Arg<SourceRef>()));
|
.Returns(ci => ExistsDuplicate(ci.Arg<SourceRef>()));
|
||||||
Store.When(s => s.AddAsync(Arg.Any<QueueItemDto>(), Arg.Any<CancellationToken>()))
|
Store.When(s => s.AddAsync(Arg.Any<QueueItemDto>(), Arg.Any<CancellationToken>()))
|
||||||
.Do(ci => _queue.Add(ci.Arg<QueueItemDto>()));
|
.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>())
|
Store.ListAsync(Arg.Any<string?>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(ci => List(ci.ArgAt<string?>(0), ci.ArgAt<int>(1)));
|
.Returns(ci => List(ci.ArgAt<string?>(0), ci.ArgAt<int>(1)));
|
||||||
|
}
|
||||||
Store.CountByStatusAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
Store.CountByStatusAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(ci => CountByStatus(ci.ArgAt<string>(0)));
|
.Returns(ci => CountByStatus(ci.ArgAt<string>(0)));
|
||||||
Store.When(s => s.SetStatusAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>()))
|
Store.When(s => s.SetStatusAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>()))
|
||||||
@@ -73,7 +88,7 @@ public sealed class TestPipelineStore
|
|||||||
.Returns(ci => ListPage(ci.ArgAt<int>(0), ci.ArgAt<int>(1)));
|
.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>())
|
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)));
|
.Returns(ci => Search(ci.ArgAt<string>(0), ci.ArgAt<int>(2)));
|
||||||
Store.CountAsync(Arg.Any<CancellationToken>()).Returns(_rejected.Count);
|
Store.CountAsync(Arg.Any<CancellationToken>()).Returns(_ => _rejected.Count);
|
||||||
Store.GetAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
Store.GetAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
|
||||||
.Returns(ci => _rejected.FirstOrDefault(item => item.Id == ci.ArgAt<string>(0)));
|
.Returns(ci => _rejected.FirstOrDefault(item => item.Id == ci.ArgAt<string>(0)));
|
||||||
Store.When(s => s.DeleteAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
|
Store.When(s => s.DeleteAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
|
||||||
|
|||||||
Reference in New Issue
Block a user