diff --git a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs index 1f34802..ccb3043 100644 --- a/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Contracts/PipelineWorkerGrpcAiTests.cs @@ -154,7 +154,7 @@ public sealed class PipelineWorkerGrpcAiTests private sealed record Context( PipelineWorkerService Worker, - FakePipelineStore PipelineStore, + TestPipelineStore PipelineStore, FakeKanjStore KanjStore, FakeSettingsStore Settings, FakeTenantLimitStore Limits); @@ -165,7 +165,7 @@ public sealed class PipelineWorkerGrpcAiTests bool budgeted = false) { var settings = new FakeSettingsStore(); - var pipelineStore = new FakePipelineStore(); + var pipelineStore = new TestPipelineStore(); var kanjStore = new FakeKanjStore(); var mlClient = new FakeMlClient { Predict = NotReadyPrediction() }; var rules = new IncomingRules(settings); @@ -192,11 +192,11 @@ public sealed class PipelineWorkerGrpcAiTests NullLogger.Instance) : grpcClassifier; - var processing = new PipelineProcessingService(pipelineStore, mlClient, new PipelineIngestService(pipelineStore)); + var processing = new PipelineProcessingService(pipelineStore.Store, mlClient, new PipelineIngestService(pipelineStore.Store)); var composer = new CardComposer(kanjStore, settings); - var writer = new PipelineCardWriter(kanjStore, pipelineStore, composer); + var writer = new PipelineCardWriter(kanjStore, pipelineStore.Store, composer); var worker = new PipelineWorkerService( - pipelineStore, settings, rules, kanjStore, mlClient, aiClassifier, processing, writer, fieldsParser); + pipelineStore.Store, settings, rules, kanjStore, mlClient, aiClassifier, processing, writer, fieldsParser); return new Context(worker, pipelineStore, kanjStore, settings, limits); } diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MlReviewServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MlReviewServiceTests.cs index 8d12a2c..757fa62 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MlReviewServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Kanban/MlReviewServiceTests.cs @@ -19,15 +19,15 @@ public sealed class MlReviewServiceTests private const string Dialog = "d_1"; private static MlReviewService Create( - FakePipelineStore pipeline, + TestPipelineStore pipeline, FakeKanjStore kanj, FakeMlClient ml, out CardsService cards) { var settings = new FakeSettingsStore(); cards = new CardsService(kanj, settings, ml, new TestFileStorage().Storage); - var processing = new PipelineProcessingService(pipeline, ml, new PipelineIngestService(pipeline)); - return new MlReviewService(pipeline, kanj, cards, processing, ml); + var processing = new PipelineProcessingService(pipeline.Store, ml, new PipelineIngestService(pipeline.Store)); + return new MlReviewService(pipeline.Store, kanj, cards, processing, ml); } private static QueueItemDto QueueRow( @@ -73,7 +73,7 @@ public sealed class MlReviewServiceTests [Fact] public async Task Candidates_MergesQueueRejectedAndCardsWithVerdicts() { - var pipeline = new FakePipelineStore(); + var pipeline = new TestPipelineStore(); pipeline.SeedQueue(QueueRow(101, "из очереди")); pipeline.SeedRejected(RejectedRow(102, "из отсева")); var kanj = new FakeKanjStore(); @@ -94,7 +94,7 @@ public sealed class MlReviewServiceTests [Fact] public async Task Candidates_FiltersByDialog() { - var pipeline = new FakePipelineStore(); + var pipeline = new TestPipelineStore(); pipeline.SeedQueue(QueueRow(101, "нужный", Dialog)); pipeline.SeedQueue(QueueRow(201, "другой", "d_2")); MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _); @@ -108,7 +108,7 @@ public sealed class MlReviewServiceTests [Fact] public async Task Candidates_NoDialog_ReturnsAllSources() { - var pipeline = new FakePipelineStore(); + var pipeline = new TestPipelineStore(); pipeline.SeedQueue(QueueRow(101, "a", "d_1")); pipeline.SeedQueue(QueueRow(201, "b", "d_2")); MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _); @@ -121,7 +121,7 @@ public sealed class MlReviewServiceTests [Fact] public async Task Candidates_ClampsLimitToMax() { - var pipeline = new FakePipelineStore(); + var pipeline = new TestPipelineStore(); for (int i = 0; i < 5; i++) { pipeline.SeedQueue(QueueRow(100 + i, $"текст {i}")); @@ -137,7 +137,7 @@ public sealed class MlReviewServiceTests [Fact] public async Task Apply_Skip_DoesNotLearn() { - var pipeline = new FakePipelineStore(); + var pipeline = new TestPipelineStore(); pipeline.SeedQueue(QueueRow(101, "текст")); var ml = new FakeMlClient(); MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _); @@ -157,7 +157,7 @@ public sealed class MlReviewServiceTests var kanj = new FakeKanjStore(); kanj.SeedCard(Card(101, "спамный текст")); var ml = new FakeMlClient(); - MlReviewService service = Create(new FakePipelineStore(), kanj, ml, out _); + MlReviewService service = Create(new TestPipelineStore(), kanj, ml, out _); MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None); @@ -172,7 +172,7 @@ public sealed class MlReviewServiceTests [Fact] public async Task Apply_Spam_QueuedMessage_RejectsAndRemovesFromQueue() { - var pipeline = new FakePipelineStore(); + var pipeline = new TestPipelineStore(); pipeline.SeedQueue(QueueRow(101, "рекламный текст")); var ml = new FakeMlClient(); MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _); @@ -202,7 +202,7 @@ public sealed class MlReviewServiceTests }); kanj.SeedCard(Card(101, "python разработчик")); var ml = new FakeMlClient(); - MlReviewService service = Create(new FakePipelineStore(), kanj, ml, out _); + MlReviewService service = Create(new TestPipelineStore(), kanj, ml, out _); MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:b_py", CancellationToken.None); @@ -218,7 +218,7 @@ public sealed class MlReviewServiceTests { var kanj = new FakeKanjStore(); kanj.SeedCard(Card(101, "текст")); - MlReviewService service = Create(new FakePipelineStore(), kanj, new FakeMlClient(), out _); + MlReviewService service = Create(new TestPipelineStore(), kanj, new FakeMlClient(), out _); MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:missing", CancellationToken.None); @@ -230,7 +230,7 @@ public sealed class MlReviewServiceTests [Fact] public async Task Apply_UnknownAction_ReturnsError() { - var pipeline = new FakePipelineStore(); + var pipeline = new TestPipelineStore(); pipeline.SeedQueue(QueueRow(101, "текст")); MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _); @@ -244,7 +244,7 @@ public sealed class MlReviewServiceTests [Fact] public async Task Apply_MessageNotFound_ReturnsNull() { - MlReviewService service = Create(new FakePipelineStore(), new FakeKanjStore(), new FakeMlClient(), out _); + MlReviewService service = Create(new TestPipelineStore(), new FakeKanjStore(), new FakeMlClient(), out _); MlApplyResult? result = await service.ApplyAsync(Dialog, 999, MlReviewService.ActionSpam, CancellationToken.None); diff --git a/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs b/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs index 05c7bdf..6e04a29 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/AdminTickOrchestratorTests.cs @@ -12,6 +12,7 @@ using Deal.Modules.Settings.Application.Models; using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Modules.Cards; using Deal.Tests.Unit.Modules.Kanban; +using NSubstitute; using Deal.Tests.Unit.Modules.Settings; using Microsoft.Extensions.Logging.Abstractions; @@ -28,7 +29,7 @@ public sealed class AdminTickOrchestratorTests // Контекст теста: оркестратор на общих фейках + канал подписки. private sealed record Context( AdminTickOrchestrator Orchestrator, - FakePipelineStore PipelineStore, + TestPipelineStore PipelineStore, FakeKanjStore KanjStore, FakeSettingsStore Settings, TestAiClassifier AiClassifier, @@ -202,18 +203,24 @@ public sealed class AdminTickOrchestratorTests { var settings = new FakeSettingsStore(); // При сбое чтения очереди подменяем хранилище целиком (тот же объект идёт во все сервисы и в контекст). - FakePipelineStore store = withThrowingQueueRead ? new ThrowingQueueReadPipelineStore() : new FakePipelineStore(); + TestPipelineStore store = new(); + if (withThrowingQueueRead) + { + store.Store.ListAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .Returns>(_ => + throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync).")); + } var kanjStore = new FakeKanjStore(); var mlClient = new FakeMlClient(); var aiClassifier = new TestAiClassifier(); var rules = new IncomingRules(settings); var fieldsParser = new LocalFieldsParser(settings); - var ingest = new PipelineIngestService(store); - var processing = new PipelineProcessingService(store, mlClient, ingest); + var ingest = new PipelineIngestService(store.Store); + var processing = new PipelineProcessingService(store.Store, mlClient, ingest); var composer = new CardComposer(kanjStore, settings); - var writer = new PipelineCardWriter(kanjStore, store, composer); + var writer = new PipelineCardWriter(kanjStore, store.Store, composer); var worker = new PipelineWorkerService( - store, settings, rules, kanjStore, mlClient, aiClassifier.Classifier, processing, writer, fieldsParser); + store.Store, settings, rules, kanjStore, mlClient, aiClassifier.Classifier, processing, writer, fieldsParser); var broker = new SseBroker(); var tickService = new StorageTickService(kanjStore, settings); @@ -261,19 +268,6 @@ public sealed class AdminTickOrchestratorTests }; } - // Хранилище со сбоем чтения очереди: ListAsync бросает (сценарий «БД/схема недоступны» на pump). - private sealed class ThrowingQueueReadPipelineStore : FakePipelineStore - { - /// - public override Task> ListAsync( - string? status, - int limit, - CancellationToken ct) - { - throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync)."); - } - } - // Строка очереди (status new; сценарий перекрывает текст/время). private static QueueItemDto QueueRow(string id, string text) => SourceItemFactory.Queue( id, diff --git a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs index 1e5bb56..110df58 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/PipelineWorkerServiceTests.cs @@ -24,7 +24,7 @@ public sealed class PipelineWorkerServiceTests // Контекст теста: воркер поверх in-memory фейков хранилищ/портов. private sealed record Context( PipelineWorkerService Worker, - FakePipelineStore PipelineStore, + TestPipelineStore PipelineStore, FakeKanjStore KanjStore, FakeSettingsStore Settings, FakeMlClient MlClient, @@ -33,20 +33,20 @@ public sealed class PipelineWorkerServiceTests // Собирает контекст: дефолты настроек/досок, ML «не готов» (как LocalMlClient), ИИ-фильтр пропускает. // pipelineStore: Хранилище очереди (по умолчанию — обычный фейк; сценарий гонки claim'а — RacingClaimPipelineStore). // Возвращает: Воркер и фейки (сценарий до-настраивает строки очереди/ответы портов). - private static Context CreateContext(FakePipelineStore? pipelineStore = null) + private static Context CreateContext(TestPipelineStore? pipelineStore = null) { var settings = new FakeSettingsStore(); - var store = pipelineStore ?? new FakePipelineStore(); + var store = pipelineStore ?? new TestPipelineStore(); var kanjStore = new FakeKanjStore(); var mlClient = new FakeMlClient { Predict = NotReadyPrediction() }; var aiClassifier = new TestAiClassifier(); var rules = new IncomingRules(settings); var fieldsParser = new LocalFieldsParser(settings); - var processing = new PipelineProcessingService(store, mlClient, new PipelineIngestService(store)); + var processing = new PipelineProcessingService(store.Store, mlClient, new PipelineIngestService(store.Store)); var composer = new CardComposer(kanjStore, settings); - var writer = new PipelineCardWriter(kanjStore, store, composer); + var writer = new PipelineCardWriter(kanjStore, store.Store, composer); var worker = new PipelineWorkerService( - store, settings, rules, kanjStore, mlClient, aiClassifier.Classifier, processing, writer, fieldsParser); + store.Store, settings, rules, kanjStore, mlClient, aiClassifier.Classifier, processing, writer, fieldsParser); return new Context(worker, store, kanjStore, settings, mlClient, aiClassifier); } @@ -54,38 +54,7 @@ public sealed class PipelineWorkerServiceTests // видит заявку, но ClaimAsync (INSERT … ON CONFLICT DO NOTHING в адаптере) возвращает false — хэш успел // заявить другой проход. DeleteClaimAsync записывается — проверяем, что чужая заявка НЕ снимается. // claimResult: Результат ClaimAsync (по умолчанию false — «победил другой проход»). - private sealed class RacingClaimPipelineStore(bool claimResult = false) : FakePipelineStore - { - /// - /// Сколько раз вызван ClaimAsync - /// - public int ClaimCalls { get; private set; } - /// - /// Сколько раз вызван DeleteClaimAsync - /// - public int DeleteClaimCalls { get; private set; } - - /// - public override Task ExistsAsync(string hash, CancellationToken ct) - { - return Task.FromResult(false); // параллельный проход ещё не вставил строку на момент проверки - } - - /// - public override Task ClaimAsync(string hash, CancellationToken ct) - { - ClaimCalls++; - return Task.FromResult(claimResult); // ON CONFLICT DO NOTHING: вставил другой проход → false - } - - /// - public override Task DeleteClaimAsync(string hash, CancellationToken ct) - { - DeleteClaimCalls++; - return Task.CompletedTask; - } - } // Сериализует значение настройки в JSON-строку строки settings (как пишет SettingsStore). // value: Значение (число/булево/строка/список). @@ -284,13 +253,13 @@ public sealed class PipelineWorkerServiceTests [Fact] public async Task Pump_ClaimLostToParallelPass_RejectsDuplicateAndDoesNotCreateCard() { - Context ctx = CreateContext(new RacingClaimPipelineStore()); + Context ctx = CreateContext(new TestPipelineStore(claimRace: true)); const string text = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; ctx.PipelineStore.SeedQueue(QueueRow("p_1", text)); PipelinePumpResult result = await ctx.Worker.PumpOnceAsync(default); - RacingClaimPipelineStore store = Assert.IsType(ctx.PipelineStore); + TestPipelineStore store = ctx.PipelineStore; Assert.Equal(1, store.ClaimCalls); // конфликт claim'а обработан, а не пропущен молча RejectedItemDto rejected = Assert.Single(ctx.PipelineStore.Rejected); Assert.Equal("dup", rejected.Stage); @@ -377,7 +346,7 @@ public sealed class PipelineWorkerServiceTests Assert.Empty(ctx.KanjStore.CardDtos); Assert.Equal(1, result.NoBudget); Assert.Equal(0, result.AiStored); // отсев фильтром не считается решением ИИ (python L1143–1147) - Assert.False(await ctx.PipelineStore.ExistsAsync(DedupHasher.Hash(text), default)); + Assert.False(await ctx.PipelineStore.Store.ExistsAsync(DedupHasher.Hash(text), default)); Assert.Null(ctx.Settings.GetStoredJson(SettingsKeys.AiDecisions)); } @@ -615,7 +584,7 @@ public sealed class PipelineWorkerServiceTests QueueItemDto row = Assert.Single(ctx.PipelineStore.Queue); Assert.Equal(PipelineQueueStatuses.Filtered, row.Status); // прошла «new»-проход, ИИ-шаг не завершился - Assert.True(await ctx.PipelineStore.ExistsAsync(DedupHasher.Hash(text), default)); // claim на месте + Assert.True(await ctx.PipelineStore.Store.ExistsAsync(DedupHasher.Hash(text), default)); // claim на месте Assert.Empty(ctx.PipelineStore.Rejected); Assert.Empty(ctx.KanjStore.CardDtos); } diff --git a/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs index 8f0538e..22e6d47 100644 --- a/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Support/StorageTickSchedulerTests.cs @@ -153,7 +153,7 @@ public sealed class StorageTickSchedulerTests var kanjStore = new FakeKanjStore(); var settings = new FakeSettingsStore(); var tenantContext = new TenantContext(); - var pipelineStoreA = new FakePipelineStore(); + var pipelineStoreA = new TestPipelineStore(); long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); pipelineStoreA.SeedRejected(Rejected("r_old_a", (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds))); pipelineStoreA.SeedRejected(Rejected("r_fresh", (long)(nowMs - TimeSpan.FromDays(1).TotalMilliseconds))); @@ -162,7 +162,7 @@ public sealed class StorageTickSchedulerTests tenantContext, new Dictionary { [TenantA] = kanjStore, [TenantB] = new FakeKanjStore() }, new Dictionary { [TenantA] = settings, [TenantB] = settings }, - new Dictionary { [TenantA] = pipelineStoreA, [TenantB] = new FakePipelineStore() }); + new Dictionary { [TenantA] = pipelineStoreA, [TenantB] = new TestPipelineStore() }); SseBroker broker = provider.GetRequiredService(); SseSubscription subscriptionA = broker.Subscribe(TenantA); @@ -192,7 +192,7 @@ public sealed class StorageTickSchedulerTests tenantContext, new Dictionary { [TenantA] = cardStoreA, [TenantB] = cardStoreB }, new Dictionary { [TenantA] = new FakeSettingsStore(), [TenantB] = new FakeSettingsStore() }, - new Dictionary { [TenantA] = new(), [TenantB] = new() }); + new Dictionary { [TenantA] = new(), [TenantB] = new() }); SseBroker broker = provider.GetRequiredService(); SseSubscription subscriptionA = broker.Subscribe(TenantA); @@ -227,7 +227,7 @@ public sealed class StorageTickSchedulerTests tenantContext, new Dictionary { [TenantA] = cardStoreA }, new Dictionary { [TenantA] = settingsA }, - new Dictionary { [TenantA] = new() }); + new Dictionary { [TenantA] = new() }); SseBroker broker = provider.GetRequiredService(); SseSubscription subscriptionA = broker.Subscribe(TenantA); @@ -253,7 +253,7 @@ public sealed class StorageTickSchedulerTests tenantContext, new Dictionary { [TenantA] = cardStoreA, [TenantB] = storeB }, new Dictionary { [TenantA] = new FakeSettingsStore(), [TenantB] = new FakeSettingsStore() }, - new Dictionary { [TenantA] = new(), [TenantB] = new() }); + new Dictionary { [TenantA] = new(), [TenantB] = new() }); SseBroker broker = provider.GetRequiredService(); SseSubscription subscriptionA = broker.Subscribe(TenantA); @@ -304,12 +304,12 @@ public sealed class StorageTickSchedulerTests TenantContext tenantContext, Dictionary storesByTenant, Dictionary settingsByTenant, - Dictionary? pipelineStoresByTenant = null) + Dictionary? pipelineStoresByTenant = null) { - pipelineStoresByTenant ??= new Dictionary + pipelineStoresByTenant ??= new Dictionary { - [TenantA] = new FakePipelineStore(), - [TenantB] = new FakePipelineStore(), + [TenantA] = new TestPipelineStore(), + [TenantB] = new TestPipelineStore(), }; var services = new ServiceCollection(); @@ -321,7 +321,7 @@ public sealed class StorageTickSchedulerTests // выбирает хранилище по тому же ITenantContext, который планировщик заполняет SetTenant. services.AddScoped(provider => storesByTenant[TenantOf(provider)]); services.AddScoped(provider => settingsByTenant[TenantOf(provider)]); - services.AddScoped(provider => pipelineStoresByTenant[TenantOf(provider)]); + services.AddScoped(provider => pipelineStoresByTenant[TenantOf(provider)].Store); services.AddSingleton(new FakeMlClient()); services.AddSingleton(new TestFileStorage().Storage); services.AddScoped(); diff --git a/src/core/tests/Deal.Tests.Unit/Support/TestPipelineStore.cs b/src/core/tests/Deal.Tests.Unit/Support/TestPipelineStore.cs new file mode 100644 index 0000000..d40b3df --- /dev/null +++ b/src/core/tests/Deal.Tests.Unit/Support/TestPipelineStore.cs @@ -0,0 +1,278 @@ +using Deal.Modules.Cards.Application.Sources; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using NSubstitute; + +namespace Deal.Tests.Unit.Support; + +/// +/// Подставка на списках: сервисы получают NSubstitute-подставку +/// (), тесты сеют/проверяют очередь и отсеянных через , +/// , , . +/// +public sealed class TestPipelineStore +{ + private readonly List _queue = []; + private readonly List _rejected = []; + private readonly Dictionary _dedup = new(StringComparer.Ordinal); + + /// + /// Подставка порта пайплайна (создаётся в конструкторе). + /// + public IPipelineStore Store { get; } + + /// + /// Строки очереди подставки + /// + public IReadOnlyList Queue => _queue.ToList(); + + /// + /// Записи отсева подставки + /// + public IReadOnlyList Rejected => _rejected.ToList(); + + private readonly bool _claimRace; + private readonly bool _claimResult; + + /// + /// Сколько раз вызван ClaimAsync + /// + public int ClaimCalls { get; private set; } + + /// + /// Сколько раз вызван DeleteClaimAsync + /// + public int DeleteClaimCalls { get; private set; } + + /// + /// Создаёт подставку с пустыми списками. + /// + /// Сценарий claim-гонки: ExistsAsync=false, ClaimAsync возвращает claimResult. + /// Результат ClaimAsync в сценарии гонки (false — хэш уже заявлен другим проходом). + public TestPipelineStore(bool claimRace = false, bool claimResult = false) + { + _claimRace = claimRace; + _claimResult = claimResult; + 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))); + Store.CountByStatusAsync(Arg.Any(), Arg.Any()) + .Returns(ci => CountByStatus(ci.ArgAt(0))); + Store.When(s => s.SetStatusAsync(Arg.Any(), Arg.Any(), Arg.Any())) + .Do(ci => SetStatus(ci.ArgAt(0), ci.ArgAt(1))); + Store.When(s => s.RemoveAsync(Arg.Any(), Arg.Any())) + .Do(ci => Remove(ci.ArgAt(0))); + Store.When(s => s.UpsertAsync(Arg.Any(), Arg.Any())) + .Do(ci => Upsert(ci.Arg())); + Store.ListPageAsync(Arg.Any(), Arg.Any(), Arg.Any()) + .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.GetAsync(Arg.Any(), Arg.Any()) + .Returns(ci => _rejected.FirstOrDefault(item => item.Id == ci.ArgAt(0))); + Store.When(s => s.DeleteAsync(Arg.Any(), Arg.Any())) + .Do(ci => _rejected.RemoveAll(item => item.Id == ci.ArgAt(0))); + Store.ClearAsync(Arg.Any()) + .Returns(ci => + { + int cleared = _rejected.Count; + _rejected.Clear(); + return cleared; + }); + Store.PurgeExpiredAsync(Arg.Any(), Arg.Any()) + .Returns(ci => PurgeExpired(ci.ArgAt(0))); + Store.When(s => s.MarkReturnedAsync( + Arg.Any(), Arg.Any(), Arg.Any(), Arg.Any())) + .Do(ci => MarkReturned(ci.ArgAt(0), ci.ArgAt(1), ci.ArgAt(2))); + Store.When(s => s.LinkAsync(Arg.Any(), Arg.Any(), Arg.Any())) + .Do(ci => _dedup[ci.ArgAt(0)] = ci.ArgAt(1)); + Store.When(s => s.DeleteByCardAsync(Arg.Any(), Arg.Any())) + .Do(ci => DeleteByCard(ci.ArgAt(0))); + Store.ExistsAsync(Arg.Any(), Arg.Any()) + .Returns(ci => !_claimRace && _dedup.ContainsKey(ci.ArgAt(0))); + Store.When(s => s.ClaimAsync(Arg.Any(), Arg.Any())) + .Do(_ => ClaimCalls++); + Store.ClaimAsync(Arg.Any(), Arg.Any()) + .Returns(ci => _claimRace + ? _claimResult + : _dedup.TryAdd(ci.ArgAt(0), null)); + Store.When(s => s.DeleteClaimAsync(Arg.Any(), Arg.Any())) + .Do(_ => DeleteClaimCalls++); + Store.When(s => s.DeleteClaimAsync(Arg.Any(), Arg.Any())) + .Do(ci => + { + if (!_claimRace && _dedup.TryGetValue(ci.ArgAt(0), out string? leadId) && leadId is null) + { + _dedup.Remove(ci.ArgAt(0)); + } + }); + } + + /// + /// Кладёт строку очереди напрямую + /// + /// Строка как если бы была сохранена в БД. + public void SeedQueue(QueueItemDto item) + { + _queue.Add(item); + } + + /// + /// Кладёт запись отсева напрямую + /// + /// Запись как если бы была сохранена в БД. + public void SeedRejected(RejectedItemDto item) + { + _rejected.Add(item); + } + + /// + /// Id карточки, связанной с хэшем дедупа + /// + /// SHA1-hex нормализованного текста. + public string? DedupLeadId(string hash) + { + return _dedup.TryGetValue(hash, out string? leadId) ? leadId : null; + } + + private bool ExistsDuplicate(SourceRef source) + { + string key = source.DedupeKey(); + return _queue.Any(item => item.Source.DedupeKey() == key); + } + + private IReadOnlyList List(string? status, int limit) + { + return _queue + .Where(item => status is null || item.Status == status) + .OrderBy(item => item.QueuedAtMs) + .Take(limit) + .ToList(); + } + + private int CountByStatus(string status) => _queue.Count(item => item.Status == status); + + private void SetStatus(string id, string status) + { + int index = _queue.FindIndex(item => item.Id == id); + if (index >= 0) + { + _queue[index] = _queue[index] with { Status = status }; + } + } + + private void Remove(string id) => _queue.RemoveAll(item => item.Id == id); + + private void Upsert(RejectRecord record) + { + if (string.IsNullOrWhiteSpace(record.Text)) + { + return; + } + + string id = record.DeterministicId ?? PrefixId.New(PipelineIdPrefixes.Rejected); + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + int index = _rejected.FindIndex(item => item.Id == id); + RejectedItemDto row = ToItem(record, id, nowMs); + if (index >= 0) + { + RejectedItemDto existing = _rejected[index]; + row = row with + { + Returned = existing.Returned, + ReturnedAtMs = existing.ReturnedAtMs, + ReturnReason = existing.ReturnReason, + }; + _rejected[index] = row; + } + else + { + _rejected.Add(row); + } + } + + private IReadOnlyList ListPage(int offset, int limit) + { + return _rejected + .OrderByDescending(item => item.RejectedAtMs) + .Skip(offset) + .Take(limit) + .ToList(); + } + + private IReadOnlyList Search(string q, int limitLike) + { + string query = q.Trim(); + return _rejected + .Where(item => LikeField(item.Text, query) + || LikeField(item.Reason, query) + || LikeField(item.Kw, query) + || LikeField(item.Source.DisplayName ?? string.Empty, query)) + .OrderByDescending(item => item.RejectedAtMs) + .Take(limitLike) + .ToList(); + } + + private int PurgeExpired(DateTimeOffset olderThan) + { + long cutMs = olderThan.ToUnixTimeMilliseconds(); + return _rejected.RemoveAll(item => item.RejectedAtMs < cutMs); + } + + private void MarkReturned(string id, string reason, DateTimeOffset returnedAt) + { + int index = _rejected.FindIndex(item => item.Id == id); + if (index >= 0) + { + _rejected[index] = _rejected[index] with + { + Returned = true, + ReturnedAtMs = returnedAt.ToUnixTimeMilliseconds(), + ReturnReason = reason, + }; + } + } + + private void DeleteByCard(string cardId) + { + foreach (string hash in _dedup.Where(pair => pair.Value == cardId).Select(pair => pair.Key).ToList()) + { + _dedup.Remove(hash); + } + } + + // Подстрока q в lower(поле) — LIKE-половина адаптера (поле колонки SQL приводится к нижнему регистру). + // field: Поле записи отсева (text/reason/kw/ch_name). + // query: Поисковый запрос, нормализованный сервисом (trim+lowercase). + // Возвращает: True — поле содержит запрос без учёта регистра поля. + private static bool LikeField(string field, string query) + { + return field.ToLowerInvariant().Contains(query, StringComparison.Ordinal); + } + + private static RejectedItemDto ToItem( + RejectRecord record, + string id, + long rejectedAtMs) => new() + { + Id = id, + Source = record.Source, + Content = record.Content, + Text = record.Text, + Stage = record.Stage, + StageLabel = PipelineRejectConstants.StageLabel(record.Stage), + Reason = record.Reason, + Kw = record.Kw, + DecidedBy = record.DecidedBy, + DecidedByLabel = PipelineRejectConstants.SourceLabel(record.DecidedBy), + MsgAtMs = record.MsgAtMs, + RejectedAtMs = rejectedAtMs, + }; +}