Перевести FakePipelineStore на NSubstitute

This commit is contained in:
2026-09-13 02:56:43 +03:00
parent d07875f146
commit 3cf95ba303
9 changed files with 103 additions and 416 deletions
@@ -49,7 +49,7 @@ public sealed class IngressRateLimitInterceptorTests
await RunAsync(
registry,
services => services.AddScoped<IPipelineStore>(_ => new FakePipelineStore()),
services => services.AddScoped<IPipelineStore>(_ => new TestPipelineStore().Store),
async channel =>
{
PushSourceReply first = await PushAsync(channel, TenantA);
@@ -77,7 +77,7 @@ public sealed class IngressRateLimitInterceptorTests
await RunAsync(
registry,
services => services.AddScoped<IPipelineStore>(_ => new FakePipelineStore()),
services => services.AddScoped<IPipelineStore>(_ => new TestPipelineStore().Store),
async channel =>
{
// Окно ингресса — 1/мин: единственный разрешённый вызов исчерпывает лимит.
@@ -29,10 +29,10 @@ public sealed class RuntimeDepthsCollectorTests
[Fact]
public async Task CollectAsync_SumsQueueAndOutboxAcrossTenants()
{
var pipelineByTenant = new Dictionary<string, FakePipelineStore>
var pipelineByTenant = new Dictionary<string, TestPipelineStore>
{
[TenantA.ToString("N")] = new FakePipelineStore(),
[TenantB.ToString("N")] = new FakePipelineStore(),
[TenantA.ToString("N")] = new TestPipelineStore(),
[TenantB.ToString("N")] = new TestPipelineStore(),
};
pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a1"));
pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a2"));
@@ -105,7 +105,7 @@ public sealed class RuntimeDepthsCollectorTests
public async Task CollectAsync_NoTenants_ReturnsZeros()
{
RuntimeDepthsCollector collector = Build(
new Dictionary<string, FakePipelineStore>(),
new Dictionary<string, TestPipelineStore>(),
new Dictionary<string, TestMlLearningStore>(),
tenants: Array.Empty<TenantRecordDto>());
@@ -123,8 +123,8 @@ public sealed class RuntimeDepthsCollectorTests
Status = PipelineQueueStatuses.New,
};
private static Dictionary<string, FakePipelineStore> PipelineStores(params Guid[] tenants)
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakePipelineStore());
private static Dictionary<string, TestPipelineStore> PipelineStores(params Guid[] tenants)
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new TestPipelineStore());
private static Dictionary<string, TestMlLearningStore> OutboxStores(params Guid[] tenants)
=> tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new TestMlLearningStore());
@@ -139,7 +139,7 @@ public sealed class RuntimeDepthsCollectorTests
// Собирает коллектор поверх tenant-scoped фейков (как реальные адаптеры по ITenantContext).
private static RuntimeDepthsCollector Build(
IReadOnlyDictionary<string, FakePipelineStore> pipelineByTenant,
IReadOnlyDictionary<string, TestPipelineStore> pipelineByTenant,
IReadOnlyDictionary<string, TestMlLearningStore> outboxByTenant,
IReadOnlyList<TenantRecordDto>? tenants = null,
ITenantLimitStore? limitStore = null)
@@ -153,7 +153,7 @@ public sealed class RuntimeDepthsCollectorTests
new TenantRecordDto(TenantB, "B", "active", DateTimeOffset.UtcNow),
}).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<PipelineIngestService>();
services.AddScoped<PipelineProcessingService>();
@@ -18,9 +18,9 @@ public sealed class PipelineCardWriterTests
[Fact]
public async Task CreateCard_CreatesInboxCardAndLinksDedupClaim()
{
(PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create();
(PipelineCardWriter writer, FakeKanjStore store, TestPipelineStore pipelineStore) = Create();
const string hash = "abcdef0123456789abcdef0123456789abcdef01";
await pipelineStore.ClaimAsync(hash, CancellationToken.None); // заявка воркера (LeadId=null, Ruling 8)
await pipelineStore.Store.ClaimAsync(hash, CancellationToken.None); // заявка воркера (LeadId=null, Ruling 8)
AiParsedCardDto parsed = Parsed(
title: "Python-разработчик на бота",
budget: new AiBudgetDto(2000, 2000, "USD"),
@@ -38,16 +38,16 @@ public sealed class PipelineCardWriterTests
Assert.Equal(new CardBudgetDto(2000, 2000, "USD"), card.Budget);
Assert.Equal(card.Id, pipelineStore.DedupLeadId(hash));
Assert.True(await pipelineStore.ExistsAsync(hash, CancellationToken.None));
Assert.True(await pipelineStore.Store.ExistsAsync(hash, CancellationToken.None));
}
[Fact]
public async Task CreateCard_AssignedBoardAccepted_WritesCardIntoBoardColumn()
{
(PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create();
(PipelineCardWriter writer, FakeKanjStore store, TestPipelineStore pipelineStore) = Create();
store.SeedBoard(new ContainerDto { Id = "b_py" });
const string hash = "abcdef0123456789abcdef0123456789abcdef01";
await pipelineStore.ClaimAsync(hash, CancellationToken.None);
await pipelineStore.Store.ClaimAsync(hash, CancellationToken.None);
CardDto card = await writer.CreateCardAsync(
Parsed(title: "Python-разработчик", board: "b_py"),
@@ -66,10 +66,10 @@ public sealed class PipelineCardWriterTests
[Fact]
public async Task CreateCard_AddCardFails_ThrowsAndDoesNotLinkDedup()
{
(PipelineCardWriter writer, FakeKanjStore store, FakePipelineStore pipelineStore) = Create();
(PipelineCardWriter writer, FakeKanjStore store, TestPipelineStore pipelineStore) = Create();
store.FailAddCard = true; // сбой адаптера записи (например, недоступна БД тенанта)
const string hash = "abcdef0123456789abcdef0123456789abcdef01";
await pipelineStore.ClaimAsync(hash, CancellationToken.None);
await pipelineStore.Store.ClaimAsync(hash, CancellationToken.None);
await Assert.ThrowsAsync<InvalidOperationException>(
() => writer.CreateCardAsync(Parsed(title: "Python-разработчик"), Message("Текст"), hash, CancellationToken.None));
@@ -77,19 +77,19 @@ public sealed class PipelineCardWriterTests
// Карточка не создана → связывать нечего: заявка дедупа осталась несвязанной (LeadId=null).
Assert.Empty(store.CardDtos);
Assert.Null(pipelineStore.DedupLeadId(hash));
Assert.True(await pipelineStore.ExistsAsync(hash, CancellationToken.None)); // заявка на месте
Assert.True(await pipelineStore.Store.ExistsAsync(hash, CancellationToken.None)); // заявка на месте
}
// ─── Хелперы ──────────────────────────────────────────────────────────────────────────
// Создаёт контекст теста: фейки канбана, пайплайна и композитор поверх них.
// Создаёт контекст теста: подставки канбана, пайплайна и композитор поверх них.
// Возвращает: Кортеж (обёртка, канбан-хранилище, хранилище пайплайна).
private static (PipelineCardWriter Writer, FakeKanjStore Store, FakePipelineStore PipelineStore) Create()
private static (PipelineCardWriter Writer, FakeKanjStore Store, TestPipelineStore PipelineStore) Create()
{
var store = new FakeKanjStore();
var pipelineStore = new FakePipelineStore();
var pipelineStore = new TestPipelineStore();
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.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Pipeline;
@@ -16,8 +15,8 @@ public sealed class PipelineIngestServiceTests
[Fact]
public async Task EnqueueAsync_TrimsTextAndWritesNewRowWithPId()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
TestPipelineStore store = new();
PipelineIngestService service = new(store.Store);
PipelineIngestResultDto result = await service.EnqueueAsync(
Message(
@@ -48,8 +47,8 @@ public sealed class PipelineIngestServiceTests
[Fact]
public async Task EnqueueAsync_MsgAtAbsent_ZeroMsgAtAndQueuedNow()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
TestPipelineStore store = new();
PipelineIngestService service = new(store.Store);
await service.EnqueueAsync(
Message("Сообщение без времени", receivedAt: default(DateTimeOffset)),
@@ -63,8 +62,8 @@ public sealed class PipelineIngestServiceTests
[Fact]
public async Task EnqueueAsync_ForceFlag_PropagatedToQueueRow()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
TestPipelineStore store = new();
PipelineIngestService service = new(store.Store);
await service.EnqueueAsync(Message("Возврат из отсева", force: true), CancellationToken.None);
@@ -76,8 +75,8 @@ public sealed class PipelineIngestServiceTests
[Fact]
public async Task EnqueueAsync_EmptyOrWhitespaceText_Noop()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
TestPipelineStore store = new();
PipelineIngestService service = new(store.Store);
PipelineIngestResultDto empty = await service.EnqueueAsync(Message(text: string.Empty), CancellationToken.None);
PipelineIngestResultDto spaces = await service.EnqueueAsync(Message(text: " "), CancellationToken.None);
@@ -93,8 +92,8 @@ public sealed class PipelineIngestServiceTests
[Fact]
public async Task EnqueueAsync_SameSourceTwice_SecondIsDuplicate()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
TestPipelineStore store = new();
PipelineIngestService service = new(store.Store);
QueuedMessage first = Message("Сообщение", origin: "d_1", externalId: "7");
QueuedMessage second = Message("Сообщение снова", origin: "d_1", externalId: "7");
@@ -111,8 +110,8 @@ public sealed class PipelineIngestServiceTests
[Fact]
public async Task EnqueueAsync_SameSourceDifferentExternalIds_BothAccepted()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
TestPipelineStore store = new();
PipelineIngestService service = new(store.Store);
await service.EnqueueAsync(Message("Первое", origin: "d_1", externalId: "1"), CancellationToken.None);
PipelineIngestResultDto second = await service.EnqueueAsync(Message("Второе", origin: "d_1", externalId: "2"), CancellationToken.None);
@@ -124,8 +123,8 @@ public sealed class PipelineIngestServiceTests
[Fact]
public async Task EnqueueAsync_SameOriginWithoutExternalId_SecondIsDuplicate()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
TestPipelineStore store = new();
PipelineIngestService service = new(store.Store);
PipelineIngestResultDto first = await service.EnqueueAsync(
Message("Первое", origin: "d_1", externalId: null), CancellationToken.None);
@@ -142,8 +141,8 @@ public sealed class PipelineIngestServiceTests
[Fact]
public async Task EnqueueAsync_LongText_TruncatedTo6000CodePoints()
{
FakePipelineStore store = new();
PipelineIngestService service = new(store);
TestPipelineStore store = new();
PipelineIngestService service = new(store.Store);
string longText = new string('а', 7_000);
await service.EnqueueAsync(Message(longText), CancellationToken.None);
@@ -3,7 +3,6 @@ using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Pipeline;
@@ -17,7 +16,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task RejectAsync_WhitespaceText_Noop()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
await service.RejectAsync(new RejectRecord { Text = " ", DecidedBy = "stop", Stage = "length" }, CancellationToken.None);
@@ -27,7 +26,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task RejectAsync_TruncatesFieldsDefaultsHueAndUsesDeterministicId()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
await service.RejectAsync(new RejectRecord
{
@@ -53,7 +52,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task RejectAsync_SameDialogAndMsgId_UpsertsSingleRow()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
await service.RejectAsync(Reject("d_1", 7, stage: "length", reason: "короткое"), CancellationToken.None);
await service.RejectAsync(Reject("d_1", 7, stage: "stop", reason: "стоп-фраза «реклама»"), CancellationToken.None);
@@ -68,7 +67,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task QueueCountsAsync_CountsNewAndFiltered()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
store.SeedQueue(QueueItem("p_1", PipelineQueueStatuses.New, queuedAtMs: 1));
store.SeedQueue(QueueItem("p_2", PipelineQueueStatuses.New, queuedAtMs: 2));
store.SeedQueue(QueueItem("p_3", PipelineQueueStatuses.Filtered, queuedAtMs: 3));
@@ -83,7 +82,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task StatsAsync_ReturnsQueueCountsAndRejectedCount()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
store.SeedQueue(QueueItem("p_1", PipelineQueueStatuses.New, queuedAtMs: 1));
store.SeedRejected(Rejected("r_1"));
store.SeedRejected(Rejected("r_2"));
@@ -99,7 +98,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ListQueueAsync_OrdersByQueuedAtAndClampsLimitToMinimumOne()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
store.SeedQueue(QueueItem("p_1", PipelineQueueStatuses.New, queuedAtMs: 30));
store.SeedQueue(QueueItem("p_2", PipelineQueueStatuses.Filtered, queuedAtMs: 10));
store.SeedQueue(QueueItem("p_3", PipelineQueueStatuses.New, queuedAtMs: 20));
@@ -116,7 +115,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ListRejectedAsync_NoQuery_NewestFirstWithOffsetAndLimit()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
store.SeedRejected(Rejected("r_1", rejectedAtMs: 1_000));
store.SeedRejected(Rejected("r_2", rejectedAtMs: 2_000));
store.SeedRejected(Rejected("r_3", rejectedAtMs: 3_000));
@@ -132,7 +131,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ListRejectedAsync_QueryMatchesTextReasonKwAndSourceName()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
store.SeedRejected(Rejected("r_text", text: "Middle Python разработчик", rejectedAtMs: 1_000));
store.SeedRejected(Rejected("r_reason", reason: "стоп-фраза «реклама»", rejectedAtMs: 2_000));
store.SeedRejected(Rejected("r_kw", kw: "спам", rejectedAtMs: 3_000));
@@ -152,7 +151,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ListRejectedAsync_QueryWithOffsetPagesOverCandidatesAndEchoesTotal()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
store.SeedRejected(Rejected("r_1", text: "middle a", rejectedAtMs: 1_000));
store.SeedRejected(Rejected("r_2", text: "middle b", rejectedAtMs: 2_000));
store.SeedRejected(Rejected("r_3", text: "middle c", rejectedAtMs: 3_000));
@@ -168,7 +167,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ReturnAsync_RecordNotFound_ReturnsNull()
{
(PipelineProcessingService service, FakePipelineStore store, TestMlClient ml) = Create();
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
RejectReturnResultDto? result = await service.ReturnAsync("r_missing", string.Empty, CancellationToken.None);
@@ -180,7 +179,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ReturnAsync_AlreadyReturned_Returns400()
{
(PipelineProcessingService service, FakePipelineStore store, TestMlClient ml) = Create();
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
store.SeedRejected(Rejected("r_1", returned: true, returnReason: "было"));
RejectReturnResultDto? result = await service.ReturnAsync("r_1", "ещё раз", CancellationToken.None);
@@ -195,7 +194,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ReturnAsync_DuplicateSource_Returns400()
{
(PipelineProcessingService service, FakePipelineStore store, TestMlClient ml) = Create();
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
store.SeedRejected(Rejected("r_1", decidedBy: "dup", stage: "dup"));
RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None);
@@ -209,7 +208,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ReturnAsync_EmptyText_Returns400()
{
(PipelineProcessingService service, FakePipelineStore store, TestMlClient ml) = Create();
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
store.SeedRejected(Rejected("r_1", text: " "));
RejectReturnResultDto? result = await service.ReturnAsync("r_1", string.Empty, CancellationToken.None);
@@ -223,7 +222,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ReturnAsync_SpamStage_UnlearnsSpamMarksRecordAndEnqueuesForceRow()
{
(PipelineProcessingService service, FakePipelineStore store, TestMlClient ml) = Create();
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
store.SeedRejected(Rejected(
"r_1",
text: " Продвижение в каналах ",
@@ -270,7 +269,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ReturnAsync_NonSpamStage_NoLearningPushButEnqueuesForceRow()
{
(PipelineProcessingService service, FakePipelineStore store, TestMlClient ml) = Create();
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
store.SeedRejected(Rejected(
"r_1",
text: "Короткое сообщение",
@@ -296,7 +295,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ReturnAsync_LongReason_TruncatedTo500CodePointsOnRecord()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
store.SeedRejected(Rejected("r_1", text: "Текст", stage: "stale", decidedBy: "stale", origin: "d_1"));
await service.ReturnAsync("r_1", new string('п', 700), CancellationToken.None);
@@ -309,7 +308,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ReturnAsync_NoSourceInfo_EnqueuesDirectlyWithDefaultHue()
{
(PipelineProcessingService service, FakePipelineStore store, TestMlClient ml) = Create();
(PipelineProcessingService service, TestPipelineStore store, TestMlClient ml) = Create();
store.SeedRejected(Rejected(
"r_old",
text: "Старая запись без ссылки на исходное",
@@ -340,7 +339,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task DeleteAsync_RemovesSingleRecord()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
store.SeedRejected(Rejected("r_1"));
store.SeedRejected(Rejected("r_2"));
@@ -352,7 +351,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task ClearAsync_ReturnsCountAndEmptiesRejects()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
store.SeedRejected(Rejected("r_1"));
store.SeedRejected(Rejected("r_2"));
store.SeedRejected(Rejected("r_3"));
@@ -368,7 +367,7 @@ public sealed class PipelineProcessingServiceTests
[Fact]
public async Task PurgeExpiredAsync_RemovesOnlyRowsOlderThanRetention()
{
(PipelineProcessingService service, FakePipelineStore store, _) = Create();
(PipelineProcessingService service, TestPipelineStore store, _) = Create();
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
store.SeedRejected(Rejected("r_old", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds)));
store.SeedRejected(Rejected("r_fresh", rejectedAtMs: (long)(nowMs - TimeSpan.FromDays(1).TotalMilliseconds)));
@@ -381,12 +380,12 @@ public sealed class PipelineProcessingServiceTests
// ─── Помощники ──────────────────────────────────────────────────────────
// Сервис на общих фейках: очередь/отсев и ML-клиент одного сценария.
private static (PipelineProcessingService Service, FakePipelineStore Store, TestMlClient Ml) Create()
// Сервис на общих подставках: очередь/отсев и ML-клиент одного сценария.
private static (PipelineProcessingService Service, TestPipelineStore Store, TestMlClient Ml) Create()
{
FakePipelineStore store = new();
TestPipelineStore store = 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);
}
@@ -41,10 +41,10 @@ public sealed class PipelineWorkerSchedulerTests
// Контекст теста: планировщик на общих фейках + каналы подписок тенантов.
private sealed record Context(
PipelineWorkerScheduler Scheduler,
FakePipelineStore PipelineA,
TestPipelineStore PipelineA,
FakeKanjStore KanjA,
SseSubscription SubscriptionA,
FakePipelineStore PipelineB,
TestPipelineStore PipelineB,
FakeKanjStore KanjB,
SseSubscription SubscriptionB,
PipelinePumpGate PumpGate,
@@ -151,8 +151,8 @@ public sealed class PipelineWorkerSchedulerTests
{
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
var tenantContext = new TenantContext();
FakePipelineStore pipelineA = withThrowingQueueReadA ? new ThrowingQueueReadPipelineStore() : new FakePipelineStore();
var pipelineB = new FakePipelineStore();
var pipelineA = new TestPipelineStore(throwOnList: withThrowingQueueReadA);
var pipelineB = new TestPipelineStore();
var kanjA = new FakeKanjStore();
var kanjB = new FakeKanjStore();
var settingsA = new TestSettingsStore();
@@ -174,7 +174,7 @@ public sealed class PipelineWorkerSchedulerTests
// (эталон StorageTickSchedulerTests/ConnectionStringProvider.ForTenant).
services.AddScoped<ICardStore>(provider => TenantOf(provider) == TenantA ? kanjA : kanjB);
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.
services.AddScoped<IncomingRules>();
services.AddScoped<LocalFieldsParser>();
@@ -267,18 +267,7 @@ public sealed class PipelineWorkerSchedulerTests
return cardIds;
}
// Хранилище со сбоем чтения очереди: ListAsync бросает (сценарий «БД/схема недоступны» на pump).
private sealed class ThrowingQueueReadPipelineStore : FakePipelineStore
{
/// <inheritdoc />
public override Task<IReadOnlyList<QueueItemDto>> ListAsync(
string? status,
int limit,
CancellationToken ct)
{
throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync).");
}
}
// Хранилище со сбоем чтения очереди задаётся флагом throwOnList в TestPipelineStore.
// Логгер-коллектор: копит сообщения планировщика (диагностика в тестах сбоя pump).
private sealed class ListLogger : ILogger<PipelineWorkerScheduler>
@@ -46,12 +46,12 @@ public sealed class SourceIngressGrpcServiceTests
[Fact]
public async Task PushSource_ValidTenant_EnqueuesQueueRowAndAccepts()
{
var store = new FakePipelineStore();
var store = new TestPipelineStore();
var registry = TestRegistry.With(Tenant(TenantA));
await RunAsync(
registry,
services => services.AddScoped<IPipelineStore>(_ => store),
services => services.AddScoped<IPipelineStore>(_ => store.Store),
async channel =>
{
PushSourceReply reply = await PushAsync(
@@ -80,12 +80,12 @@ public sealed class SourceIngressGrpcServiceTests
[Fact]
public async Task PushSource_SameSourceKeyTwice_SecondIsDuplicateAndQueueNotGrown()
{
var store = new FakePipelineStore();
var store = new TestPipelineStore();
var registry = TestRegistry.With(Tenant(TenantA));
await RunAsync(
registry,
services => services.AddScoped<IPipelineStore>(_ => store),
services => services.AddScoped<IPipelineStore>(_ => store.Store),
async channel =>
{
PushSourceReply first = await PushAsync(channel, Request("Сообщение канала", externalId: "42"), TenantA, ValidToken);
@@ -104,12 +104,12 @@ public sealed class SourceIngressGrpcServiceTests
[Fact]
public async Task PushSource_EmptyText_NotAcceptedAndQueueNotGrown()
{
var store = new FakePipelineStore();
var store = new TestPipelineStore();
var registry = TestRegistry.With(Tenant(TenantA));
await RunAsync(
registry,
services => services.AddScoped<IPipelineStore>(_ => store),
services => services.AddScoped<IPipelineStore>(_ => store.Store),
async channel =>
{
PushSourceReply reply = await PushAsync(channel, Request(" ", externalId: "9"), TenantA, ValidToken);
@@ -126,12 +126,12 @@ public sealed class SourceIngressGrpcServiceTests
[Fact]
public async Task PushSource_UnknownTenant_NotAcceptedWithoutRpcError()
{
var store = new FakePipelineStore();
var store = new TestPipelineStore();
var registry = TestRegistry.With(Tenant(TenantA));
await RunAsync(
registry,
services => services.AddScoped<IPipelineStore>(_ => store),
services => services.AddScoped<IPipelineStore>(_ => store.Store),
async channel =>
{
PushSourceReply reply = await PushAsync(channel, Request("Сообщение чужого тенанта", externalId: "5"), Guid.NewGuid(), ValidToken);
@@ -150,10 +150,10 @@ public sealed class SourceIngressGrpcServiceTests
[Fact]
public async Task PushSource_WithoutToken_IsUnauthenticated()
{
var store = new FakePipelineStore();
var store = new TestPipelineStore();
var registry = TestRegistry.With(Tenant(TenantA));
await RunAsync(registry, services => services.AddScoped<IPipelineStore>(_ => store), async channel =>
await RunAsync(registry, services => services.AddScoped<IPipelineStore>(_ => store.Store), async channel =>
{
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => PushAsync(channel, Request("текст", externalId: "1"), TenantA, null));
@@ -167,10 +167,10 @@ public sealed class SourceIngressGrpcServiceTests
[Fact]
public async Task PushSource_WithWrongToken_IsUnauthenticated()
{
var store = new FakePipelineStore();
var store = new TestPipelineStore();
var registry = TestRegistry.With(Tenant(TenantA));
await RunAsync(registry, services => services.AddScoped<IPipelineStore>(_ => store), async channel =>
await RunAsync(registry, services => services.AddScoped<IPipelineStore>(_ => store.Store), async channel =>
{
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => PushAsync(channel, Request("текст", externalId: "2"), TenantA, "wrong-token"));
@@ -184,7 +184,7 @@ public sealed class SourceIngressGrpcServiceTests
[Fact]
public async Task PushSource_UnsetEnvToken_FailsClosed()
{
var store = new FakePipelineStore();
var store = new TestPipelineStore();
var registry = TestRegistry.With(Tenant(TenantA));
await TelegramIngressTestHost.RunAsync(
@@ -192,7 +192,7 @@ public sealed class SourceIngressGrpcServiceTests
configureServices: services =>
{
services.AddSingleton<ITenantRepository>(registry);
services.AddScoped<IPipelineStore>(_ => store);
services.AddScoped<IPipelineStore>(_ => store.Store);
},
scenario: async channel =>
{
@@ -208,10 +208,10 @@ public sealed class SourceIngressGrpcServiceTests
[Fact]
public async Task PushSource_WithoutTenantIdMetadata_IsUnauthenticated()
{
var store = new FakePipelineStore();
var store = new TestPipelineStore();
var registry = TestRegistry.With(Tenant(TenantA));
await RunAsync(registry, services => services.AddScoped<IPipelineStore>(_ => store), async channel =>
await RunAsync(registry, services => services.AddScoped<IPipelineStore>(_ => store.Store), async channel =>
{
RpcException exception = await Assert.ThrowsAsync<RpcException>(
() => PushAsync(channel, Request("текст", externalId: "4"), null, ValidToken));
@@ -34,6 +34,7 @@ public sealed class TestPipelineStore
private readonly bool _claimRace;
private readonly bool _claimResult;
private readonly bool _throwOnList;
/// <summary>
/// Сколько раз вызван ClaimAsync
@@ -50,17 +51,31 @@ public sealed class TestPipelineStore
/// </summary>
/// <param name="claimRace">Сценарий claim-гонки: ExistsAsync=false, ClaimAsync возвращает claimResult.</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;
_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>()));
Store.ListAsync(Arg.Any<string?>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => List(ci.ArgAt<string?>(0), ci.ArgAt<int>(1)));
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>()))
@@ -73,7 +88,7 @@ public sealed class TestPipelineStore
.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.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>()))