diff --git a/src/core/tests/Deal.Tests.Unit/Api/IngressRateLimitInterceptorTests.cs b/src/core/tests/Deal.Tests.Unit/Api/IngressRateLimitInterceptorTests.cs index da58789..7928bdb 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/IngressRateLimitInterceptorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/IngressRateLimitInterceptorTests.cs @@ -49,7 +49,7 @@ public sealed class IngressRateLimitInterceptorTests await RunAsync( registry, - services => services.AddScoped(_ => new FakePipelineStore()), + services => services.AddScoped(_ => 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(_ => new FakePipelineStore()), + services => services.AddScoped(_ => new TestPipelineStore().Store), async channel => { // Окно ингресса — 1/мин: единственный разрешённый вызов исчерпывает лимит. diff --git a/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs b/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs index 98cc00e..e4c14ac 100644 --- a/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Api/RuntimeDepthsCollectorTests.cs @@ -29,10 +29,10 @@ public sealed class RuntimeDepthsCollectorTests [Fact] public async Task CollectAsync_SumsQueueAndOutboxAcrossTenants() { - var pipelineByTenant = new Dictionary + var pipelineByTenant = new Dictionary { - [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(), + new Dictionary(), new Dictionary(), tenants: Array.Empty()); @@ -123,8 +123,8 @@ public sealed class RuntimeDepthsCollectorTests Status = PipelineQueueStatuses.New, }; - private static Dictionary PipelineStores(params Guid[] tenants) - => tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new FakePipelineStore()); + private static Dictionary PipelineStores(params Guid[] tenants) + => tenants.ToDictionary(tenant => tenant.ToString("N"), _ => new TestPipelineStore()); private static Dictionary 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 pipelineByTenant, + IReadOnlyDictionary pipelineByTenant, IReadOnlyDictionary outboxByTenant, IReadOnlyList? tenants = null, ITenantLimitStore? limitStore = null) @@ -153,7 +153,7 @@ public sealed class RuntimeDepthsCollectorTests new TenantRecordDto(TenantB, "B", "active", DateTimeOffset.UtcNow), }).ToArray()).Repository); - services.AddScoped(provider => pipelineByTenant[CurrentTenant(provider)]); + services.AddScoped(provider => pipelineByTenant[CurrentTenant(provider)].Store); services.AddScoped(_ => new TestMlClient().Client); services.AddScoped(); services.AddScoped(); diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineCardWriterTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineCardWriterTests.cs index 77c172c..f6e5c5b 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineCardWriterTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineCardWriterTests.cs @@ -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( () => 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); } // Разбор карточки со значениями по умолчанию (сценарий теста перекрывает нужные поля). diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FakePipelineStore.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FakePipelineStore.cs deleted file mode 100644 index 742b6f3..0000000 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/FakePipelineStore.cs +++ /dev/null @@ -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; - -/// -/// In-memory реализация для unit-тестов PipelineIngestService/PipelineProcessingService. -/// -public class FakePipelineStore : IPipelineStore -{ - private readonly List _queue = []; - private readonly List _rejected = []; - private readonly Dictionary _dedup = new(StringComparer.Ordinal); - - /// - /// Строки очереди фейка - /// - public IReadOnlyList Queue => _queue.ToList(); - - /// - /// Записи отсева фейка - /// - public IReadOnlyList Rejected => _rejected.ToList(); - - /// - /// Кладёт строку очереди напрямую - /// - /// Строка как если бы была сохранена в БД. - public void SeedQueue(QueueItemDto item) - { - _queue.Add(item); - } - - /// - /// Кладёт запись отсева напрямую - /// - /// Запись как если бы была сохранена в БД. - public void SeedRejected(RejectedItemDto item) - { - _rejected.Add(item); - } - - // ── Очередь (QueueItems) ─────────────────────────────────────────────── - - /// - public Task ExistsDuplicateAsync( - SourceRef source, - CancellationToken ct) - { - string key = source.DedupeKey(); - bool found = _queue.Any(item => item.Source.DedupeKey() == key); - return Task.FromResult(found); - } - - /// - public Task AddAsync(QueueItemDto item, CancellationToken ct) - { - _queue.Add(item); - return Task.CompletedTask; - } - - /// - public virtual Task> ListAsync( - string? status, - int limit, - CancellationToken ct) - { - IReadOnlyList items = _queue - .Where(item => status is null || item.Status == status) - .OrderBy(item => item.QueuedAtMs) - .Take(limit) - .ToList(); - return Task.FromResult(items); - } - - /// - public Task CountByStatusAsync(string status, CancellationToken ct) - { - return Task.FromResult(_queue.Count(item => item.Status == status)); - } - - /// - 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; - } - - /// - public Task RemoveAsync(string id, CancellationToken ct) - { - _queue.RemoveAll(item => item.Id == id); - return Task.CompletedTask; - } - - // ── Отсев (RejectedItems) ────────────────────────────────────────────── - - /// - 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; - } - - /// - public Task> ListPageAsync( - int offset, - int limit, - CancellationToken ct) - { - IReadOnlyList page = _rejected - .OrderByDescending(item => item.RejectedAtMs) - .Skip(offset) - .Take(limit) - .ToList(); - return Task.FromResult(page); - } - - /// - public Task> SearchAsync( - string q, - int limitFts, - int limitLike, - CancellationToken ct) - { - string query = q.Trim(); - IReadOnlyList 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); - } - - /// - public Task CountAsync(CancellationToken ct) - { - return Task.FromResult(_rejected.Count); - } - - /// - public Task GetAsync(string id, CancellationToken ct) - { - return Task.FromResult(_rejected.FirstOrDefault(item => item.Id == id)); - } - - /// - public Task DeleteAsync(string id, CancellationToken ct) - { - _rejected.RemoveAll(item => item.Id == id); - return Task.CompletedTask; - } - - /// - public Task ClearAsync(CancellationToken ct) - { - int cleared = _rejected.Count; - _rejected.Clear(); - return Task.FromResult(cleared); - } - - /// - public Task PurgeExpiredAsync(DateTimeOffset olderThan, CancellationToken ct) - { - long cutMs = olderThan.ToUnixTimeMilliseconds(); - int removed = _rejected.RemoveAll(item => item.RejectedAtMs < cutMs); - return Task.FromResult(removed); - } - - /// - 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) ─────────────────────────────────────────────── - - /// - /// Id карточки, связанной с хэшем дедупа - /// - /// SHA1-hex нормализованного текста. - public string? DedupLeadId(string hash) - { - return _dedup.TryGetValue(hash, out string? leadId) ? leadId : null; - } - - /// - public virtual Task ExistsAsync(string hash, CancellationToken ct) - { - return Task.FromResult(_dedup.ContainsKey(hash)); - } - - /// - public virtual Task ClaimAsync(string hash, CancellationToken ct) - { - // Атомарность claim'а (как PipelineStore.ClaimAsync, ON CONFLICT DO NOTHING): true — заявка занята - // этим вызовом, false — хэш уже заявлен (TryAdd) другим проходом pump. - return Task.FromResult(_dedup.TryAdd(hash, null)); - } - - /// - public virtual Task DeleteClaimAsync(string hash, CancellationToken ct) - { - if (_dedup.TryGetValue(hash, out string? leadId) && leadId is null) - { - _dedup.Remove(hash); - } - - return Task.CompletedTask; - } - - /// - public Task LinkAsync( - string hash, - string cardId, - CancellationToken ct) - { - _dedup[hash] = cardId; - return Task.CompletedTask; - } - - /// - 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, - }; -} diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineIngestServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineIngestServiceTests.cs index 3262c9d..fc224d5 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineIngestServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineIngestServiceTests.cs @@ -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); diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineProcessingServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineProcessingServiceTests.cs index 1e96f9a..6ea34d0 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineProcessingServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Pipeline/PipelineProcessingServiceTests.cs @@ -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); } diff --git a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs index 4c864f6..2ad302e 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerSchedulerTests.cs @@ -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(provider => TenantOf(provider) == TenantA ? kanjA : kanjB); services.AddScoped(provider => TenantOf(provider) == TenantA ? settingsA.Store : settingsB.Store); - services.AddScoped(provider => TenantOf(provider) == TenantA ? pipelineA : pipelineB); + services.AddScoped(provider => TenantOf(provider) == TenantA ? pipelineA.Store : pipelineB.Store); // Реальные сервисы модуля Pipeline — как AddPipelineModule в Program.cs: цикл резолвит их в tenant-scope. services.AddScoped(); services.AddScoped(); @@ -267,18 +267,7 @@ public sealed class PipelineWorkerSchedulerTests return cardIds; } - // Хранилище со сбоем чтения очереди: ListAsync бросает (сценарий «БД/схема недоступны» на pump). - private sealed class ThrowingQueueReadPipelineStore : FakePipelineStore - { - /// - public override Task> ListAsync( - string? status, - int limit, - CancellationToken ct) - { - throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync)."); - } - } + // Хранилище со сбоем чтения очереди задаётся флагом throwOnList в TestPipelineStore. // Логгер-коллектор: копит сообщения планировщика (диагностика в тестах сбоя pump). private sealed class ListLogger : ILogger diff --git a/src/core/tests/Deal.Tests.Unit/Support/SourceIngressGrpcServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/SourceIngressGrpcServiceTests.cs index 8828b88..4e62467 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/SourceIngressGrpcServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/SourceIngressGrpcServiceTests.cs @@ -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(_ => store), + services => services.AddScoped(_ => 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(_ => store), + services => services.AddScoped(_ => 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(_ => store), + services => services.AddScoped(_ => 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(_ => store), + services => services.AddScoped(_ => 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(_ => store), async channel => + await RunAsync(registry, services => services.AddScoped(_ => store.Store), async channel => { RpcException exception = await Assert.ThrowsAsync( () => 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(_ => store), async channel => + await RunAsync(registry, services => services.AddScoped(_ => store.Store), async channel => { RpcException exception = await Assert.ThrowsAsync( () => 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(registry); - services.AddScoped(_ => store); + services.AddScoped(_ => 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(_ => store), async channel => + await RunAsync(registry, services => services.AddScoped(_ => store.Store), async channel => { RpcException exception = await Assert.ThrowsAsync( () => PushAsync(channel, Request("текст", externalId: "4"), null, ValidToken)); diff --git a/src/core/tests/Deal.Tests.Unit/Support/TestPipelineStore.cs b/src/core/tests/Deal.Tests.Unit/Support/TestPipelineStore.cs index d40b3df..c169990 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/TestPipelineStore.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/TestPipelineStore.cs @@ -34,6 +34,7 @@ public sealed class TestPipelineStore private readonly bool _claimRace; private readonly bool _claimResult; + private readonly bool _throwOnList; /// /// Сколько раз вызван ClaimAsync @@ -50,17 +51,31 @@ public sealed class TestPipelineStore /// /// Сценарий claim-гонки: ExistsAsync=false, ClaimAsync возвращает claimResult. /// Результат ClaimAsync в сценарии гонки (false — хэш уже заявлен другим проходом). - public TestPipelineStore(bool claimRace = false, bool claimResult = false) + /// Сценарий сбоя чтения очереди: ListAsync бросает (имитация недоступной схемы/БД). + public TestPipelineStore( + bool claimRace = false, + bool claimResult = false, + bool throwOnList = false) { _claimRace = claimRace; _claimResult = claimResult; + _throwOnList = throwOnList; Store = Substitute.For(); Store.ExistsDuplicateAsync(Arg.Any(), Arg.Any()) .Returns(ci => ExistsDuplicate(ci.Arg())); Store.When(s => s.AddAsync(Arg.Any(), Arg.Any())) .Do(ci => _queue.Add(ci.Arg())); - Store.ListAsync(Arg.Any(), Arg.Any(), Arg.Any()) - .Returns(ci => List(ci.ArgAt(0), ci.ArgAt(1))); + if (_throwOnList) + { + Store.ListAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns>>(_ => + throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync).")); + } + else + { + Store.ListAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns(ci => List(ci.ArgAt(0), ci.ArgAt(1))); + } Store.CountByStatusAsync(Arg.Any(), Arg.Any()) .Returns(ci => CountByStatus(ci.ArgAt(0))); Store.When(s => s.SetStatusAsync(Arg.Any(), Arg.Any(), Arg.Any())) @@ -73,7 +88,7 @@ public sealed class TestPipelineStore .Returns(ci => ListPage(ci.ArgAt(0), ci.ArgAt(1))); Store.SearchAsync(Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any()) .Returns(ci => Search(ci.ArgAt(0), ci.ArgAt(2))); - Store.CountAsync(Arg.Any()).Returns(_rejected.Count); + Store.CountAsync(Arg.Any()).Returns(_ => _rejected.Count); Store.GetAsync(Arg.Any(), Arg.Any()) .Returns(ci => _rejected.FirstOrDefault(item => item.Id == ci.ArgAt(0))); Store.When(s => s.DeleteAsync(Arg.Any(), Arg.Any()))