Перевести FakeKanjStore на NSubstitute
ci / build-test (pull_request) Successful in 3m13s

This commit is contained in:
2026-09-13 03:00:01 +03:00
parent 3cf95ba303
commit 93cadcb689
22 changed files with 541 additions and 609 deletions
@@ -6,7 +6,6 @@ using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
@@ -109,7 +108,7 @@ public sealed class CardComposerTests
[Fact]
public async Task BuildAsync_BoardWithoutRules_PlacesCardIntoBoardWithEmptyMatchHits()
{
(CardComposer composer, FakeKanjStore store, _) = Create();
(CardComposer composer, TestKanjStore store, _) = Create();
store.SeedBoard(new ContainerDto { Id = "b_py" });
CardSnapshot snapshot = await composer.BuildAsync(
@@ -125,7 +124,7 @@ public sealed class CardComposerTests
[Fact]
public async Task BuildAsync_BoardWithNonMatchingRules_FallsBackToInbox()
{
(CardComposer composer, FakeKanjStore store, _) = Create();
(CardComposer composer, TestKanjStore store, _) = Create();
store.SeedBoard(BoardWithRules("b_wpf", new[] { "wpf" }));
CardSnapshot snapshot = await composer.BuildAsync(
@@ -141,7 +140,7 @@ public sealed class CardComposerTests
[Fact]
public async Task BuildAsync_BoardWithMatchingRules_PlacesCardAndComputesMatchHits()
{
(CardComposer composer, FakeKanjStore store, _) = Create();
(CardComposer composer, TestKanjStore store, _) = Create();
store.SeedBoard(BoardWithRules("b_py", new[] { "python" }));
CardSnapshot snapshot = await composer.BuildAsync(
@@ -232,11 +231,11 @@ public sealed class CardComposerTests
// Создаёт контекст теста: пустые хранилища (дефолты: конверсия включена, курсы — мок).
// Возвращает: Кортеж (композитор, канбан-хранилище, KV-настройки).
private static (CardComposer Composer, FakeKanjStore Store, TestSettingsStore Settings) Create()
private static (CardComposer Composer, TestKanjStore Store, TestSettingsStore Settings) Create()
{
var store = new FakeKanjStore();
var store = new TestKanjStore();
var settings = new TestSettingsStore();
return (new CardComposer(store, settings.Store), store, settings);
return (new CardComposer(store.Store, settings.Store), store, settings);
}
// Разбор карточки со значениями по умолчанию (сценарий теста перекрывает нужные поля).
@@ -3,7 +3,6 @@ using Deal.Tests.Unit.Support;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Contracts;
@@ -17,7 +16,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task ListCards_NoCol_ReturnsAllOrderedByReceivedAtDesc()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", receivedAtMs: 100));
store.SeedCard(Card("l_2", "b_x", receivedAtMs: 300));
@@ -30,7 +29,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task ListCards_ByColumn_ReturnsOnlyColumnCards()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox"));
store.SeedCard(Card("l_2", KanbanColumns.Trash));
@@ -43,7 +42,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task GetCard_Existing_ReturnsCard()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", title: "Middle Python"));
CardDto? card = await service.GetCardAsync("l_1", CancellationToken.None);
@@ -66,7 +65,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_ToBoardWithRules_UpdatesColumnAndComputesMatchHits()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Middle Python-разработчик на бота, 1600$."));
@@ -84,7 +83,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_ToBoard_WritesJournalAndPush()
{
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
@@ -105,7 +104,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_ToInbox_WritesJournalWithoutPush()
{
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", prevCol: "inbox"));
@@ -124,7 +123,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_ToUnknownBoard_Returns400Text()
{
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_ghost", CancellationToken.None);
@@ -148,7 +147,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_CardMissing_ReturnsNullLead()
{
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py"));
CardResultDto result = await service.MoveDashboardCardAsync("l_ghost", "b_py", CancellationToken.None);
@@ -162,7 +161,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_SameColumn_NoOpWithoutJournalAndPush()
{
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", isNew: true));
@@ -179,7 +178,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_ToBoardWithoutRules_MatchHitsEmptyButPushSent()
{
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_free")); // правил нет
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Любой текст"));
@@ -195,7 +194,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_FromBoardToBoard_PrevColIsOldBoardAndPushNewLabel()
{
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_java", Rules(stack: new[] { "java" })));
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "b_java", sourceMsg: "Нужен Java-разработчик", prevCol: "inbox"));
@@ -214,7 +213,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_EmptySourceText_UsesTitleForMatchHitsAndPush()
{
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", sourceMsg: " "));
@@ -228,7 +227,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_EmptySourceAndTitle_JournalWithoutPush()
{
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", "inbox"));
@@ -241,7 +240,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_ToBoardWithGradeAndBudget_HitsIncludeGradeWordAndBudget()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedBoard(Board("b_mid", Rules(
grade: new[] { "middle" },
budget: new BudgetRangeDto(1000, 3000, "USD"))));
@@ -263,7 +262,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_FromArchive_Returns400AndWritesNothing()
{
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая вакансия", prevCol: "b_py"));
@@ -279,7 +278,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Move_FromTrash_Returns400AndWritesNothing()
{
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Спам-текст", prevCol: "b_py"));
@@ -296,7 +295,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Trash_FromInbox_MovesToTrashWithJournalAndSpamPush()
{
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Предлагаю услуги продвижения", isNew: true));
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
@@ -318,7 +317,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Trash_AlreadyInTrash_NoOpWithoutJournalAndPush()
{
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст"));
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
@@ -331,7 +330,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Trash_FromArchive_WritesJournalWithoutPush()
{
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py"));
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
@@ -358,7 +357,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Restore_FromTrash_ToPrevColBoard_UnlearnsSpam()
{
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Нужен Python-разработчик", prevCol: "b_py"));
@@ -383,7 +382,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Restore_FromArchive_NoPush()
{
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py"));
@@ -399,7 +398,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Restore_FromTrash_PrevColDeletedBoard_FallsBackToInbox()
{
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст", prevCol: "b_gone"));
string? back = await service.RestoreCardAsync("l_1", CancellationToken.None);
@@ -414,7 +413,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Restore_FromTrash_PrevColInbox_ReturnsInbox()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст", prevCol: "inbox"));
string? back = await service.RestoreCardAsync("l_1", CancellationToken.None);
@@ -439,7 +438,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task DeleteForever_RemovesCardAndComments_KeepsJournal()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Trash,
sourceMsg: "Нужен Python-разработчик",
comments: [new CardCommentDto("cm_1", "Вы", "Перезвонить", "5 мин")]));
@@ -454,7 +453,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task DeleteForever_JournalRowsSurviveCardDeletion()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
@@ -480,7 +479,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task ClearCol_Trash_ReturnsClearedCount()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", KanbanColumns.Trash));
store.SeedCard(Card("l_2", KanbanColumns.Trash));
store.SeedCard(Card("l_3", KanbanColumns.Archive));
@@ -507,7 +506,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task ClearCol_Board_Returns400Text()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedBoard(Board("b_py"));
store.SeedCard(Card("l_1", "b_py"));
@@ -531,7 +530,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task MarkSeen_ById_OnlyThatCard()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", isNew: true));
store.SeedCard(Card("l_2", "inbox", isNew: true));
@@ -544,7 +543,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task MarkSeen_ByCol_OnlyColumnCards()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", isNew: true));
store.SeedCard(Card("l_2", "inbox", isNew: true));
store.SeedCard(Card("l_3", "b_py", isNew: true));
@@ -558,7 +557,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task MarkSeen_All_WithoutParameters()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", isNew: true));
store.SeedCard(Card("l_2", KanbanColumns.Trash, isNew: true));
@@ -571,7 +570,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task AddComment_EmptyOrWhitespaceText_Returns400Text()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox"));
AddCommentResultDto empty = await service.AddCommentAsync("l_1", " ", CancellationToken.None);
@@ -585,7 +584,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task AddComment_Valid_AppendsTrimmedCommentWithJournal()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox"));
AddCommentResultDto result = await service.AddCommentAsync("l_1", " Перезвонить завтра ", CancellationToken.None);
@@ -618,7 +617,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Counts_ColumnsNewAndMlStats()
{
(CardsService service, FakeKanjStore store, _, TestMlClient ml) = Create();
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
store.SeedCard(Card("l_1", "inbox", isNew: true));
store.SeedCard(Card("l_2", "inbox"));
store.SeedCard(Card("l_3", "b_py"));
@@ -652,7 +651,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Search_QueryShorterThanTwoChars_ReturnsEmptyAndDoesNotCallStore()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", title: "Python-разработчик"));
IReadOnlyList<CardDto> one = await service.SearchCardsAsync("p", CancellationToken.None);
@@ -666,7 +665,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Search_DelegatesToStoreWithQueryAndLimit()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300));
IReadOnlyList<CardDto> result = await service.SearchCardsAsync(" pYtHoN ", CancellationToken.None);
@@ -681,7 +680,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Search_FindsByTitleSummaryContactSourceText_OrderedByReceivedAtDesc()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("l_title", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300));
store.SeedCard(Card("l_summary", "b_py", summary: "проект на python", receivedAtMs: 200));
store.SeedCard(Card("l_contact", "inbox", contact: "@python_dev", receivedAtMs: 100));
@@ -696,7 +695,7 @@ public sealed class CardsServiceTests
[Fact]
public async Task Search_LimitIsTwelve()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
for (int i = 0; i < 15; i++)
{
store.SeedCard(Card($"l_{i:00}", "inbox", title: $"python {i}", receivedAtMs: i));
@@ -711,12 +710,12 @@ public sealed class CardsServiceTests
// ─── Хелперы ──────────────────────────────────────────────────────────
private static (CardsService Service, FakeKanjStore Store, TestSettingsStore Settings, TestMlClient Ml) Create()
private static (CardsService Service, TestKanjStore Store, TestSettingsStore Settings, TestMlClient Ml) Create()
{
var store = new FakeKanjStore();
var store = new TestKanjStore();
var settings = new TestSettingsStore();
var ml = new TestMlClient();
return (new CardsService(store, settings.Store, ml.Client, new TestFileStorage().Storage), store, settings, ml);
return (new CardsService(store.Store, settings.Store, ml.Client, new TestFileStorage().Storage), store, settings, ml);
}
// Доска с правилами (ContainerRulesDto) либо без них.
@@ -13,7 +13,6 @@ using Deal.Modules.Tenants.Application.Abstractions;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants;
@@ -146,7 +145,7 @@ public sealed class IntegrationsDiTests
services.AddSingleton<ITenantContext>(tenantContext);
services.AddScoped<ISettingsStore>(_ => new TestSettingsStore().Store);
services.AddScoped<ISecretCipher>(_ => TestCiphers.New());
services.AddScoped<ICardStore>(_ => new FakeKanjStore());
services.AddScoped<ICardStore>(_ => new TestKanjStore().Store);
services.AddScoped<IMlLearningStore>(_ => new TestMlLearningStore().Store);
services.AddScoped<ITenantLimitStore>(_ => new TestTenantLimitStore().Store);
services.AddScoped<TokenUsageEventService>(_ => new TokenUsageEventService(new TestTokenUsageEventStore().Store));
@@ -2,7 +2,6 @@ using Deal.Contracts.Integrations.Models;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
@@ -18,7 +17,7 @@ public sealed class PipelineCardWriterTests
[Fact]
public async Task CreateCard_CreatesInboxCardAndLinksDedupClaim()
{
(PipelineCardWriter writer, FakeKanjStore store, TestPipelineStore pipelineStore) = Create();
(PipelineCardWriter writer, TestKanjStore store, TestPipelineStore pipelineStore) = Create();
const string hash = "abcdef0123456789abcdef0123456789abcdef01";
await pipelineStore.Store.ClaimAsync(hash, CancellationToken.None); // заявка воркера (LeadId=null, Ruling 8)
AiParsedCardDto parsed = Parsed(
@@ -44,7 +43,7 @@ public sealed class PipelineCardWriterTests
[Fact]
public async Task CreateCard_AssignedBoardAccepted_WritesCardIntoBoardColumn()
{
(PipelineCardWriter writer, FakeKanjStore store, TestPipelineStore pipelineStore) = Create();
(PipelineCardWriter writer, TestKanjStore store, TestPipelineStore pipelineStore) = Create();
store.SeedBoard(new ContainerDto { Id = "b_py" });
const string hash = "abcdef0123456789abcdef0123456789abcdef01";
await pipelineStore.Store.ClaimAsync(hash, CancellationToken.None);
@@ -66,7 +65,7 @@ public sealed class PipelineCardWriterTests
[Fact]
public async Task CreateCard_AddCardFails_ThrowsAndDoesNotLinkDedup()
{
(PipelineCardWriter writer, FakeKanjStore store, TestPipelineStore pipelineStore) = Create();
(PipelineCardWriter writer, TestKanjStore store, TestPipelineStore pipelineStore) = Create();
store.FailAddCard = true; // сбой адаптера записи (например, недоступна БД тенанта)
const string hash = "abcdef0123456789abcdef0123456789abcdef01";
await pipelineStore.Store.ClaimAsync(hash, CancellationToken.None);
@@ -84,12 +83,12 @@ public sealed class PipelineCardWriterTests
// Создаёт контекст теста: подставки канбана, пайплайна и композитор поверх них.
// Возвращает: Кортеж (обёртка, канбан-хранилище, хранилище пайплайна).
private static (PipelineCardWriter Writer, FakeKanjStore Store, TestPipelineStore PipelineStore) Create()
private static (PipelineCardWriter Writer, TestKanjStore Store, TestPipelineStore PipelineStore) Create()
{
var store = new FakeKanjStore();
var store = new TestKanjStore();
var pipelineStore = new TestPipelineStore();
var composer = new CardComposer(store, new TestSettingsStore().Store);
return (new PipelineCardWriter(store, pipelineStore.Store, composer), store, pipelineStore);
var composer = new CardComposer(store.Store, new TestSettingsStore().Store);
return (new PipelineCardWriter(store.Store, pipelineStore.Store, composer), store, pipelineStore);
}
// Разбор карточки со значениями по умолчанию (сценарий теста перекрывает нужные поля).
@@ -15,7 +15,6 @@ using Deal.Modules.Tenants.Application.Models;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Grpc;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants;
@@ -155,7 +154,7 @@ public sealed class PipelineWorkerGrpcAiTests
private sealed record Context(
PipelineWorkerService Worker,
TestPipelineStore PipelineStore,
FakeKanjStore KanjStore,
TestKanjStore KanjStore,
TestSettingsStore Settings,
TestTenantLimitStore Limits);
@@ -166,7 +165,7 @@ public sealed class PipelineWorkerGrpcAiTests
{
var settings = new TestSettingsStore();
var pipelineStore = new TestPipelineStore();
var kanjStore = new FakeKanjStore();
var kanjStore = new TestKanjStore();
var mlClient = new TestMlClient { Predict = NotReadyPrediction() };
var rules = new IncomingRules(settings.Store);
var fieldsParser = new LocalFieldsParser(settings.Store);
@@ -180,7 +179,7 @@ public sealed class PipelineWorkerGrpcAiTests
tenantContext,
connection,
new AiProviderConfigBuilder(settings.Store, TestCiphers.New()),
new AiClassifyContextBuilder(settings.Store, kanjStore),
new AiClassifyContextBuilder(settings.Store, kanjStore.Store),
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiClassifier>.Instance);
IAiClassifier aiClassifier = budgeted
@@ -193,10 +192,10 @@ public sealed class PipelineWorkerGrpcAiTests
: grpcClassifier;
var processing = new PipelineProcessingService(pipelineStore.Store, mlClient.Client, new PipelineIngestService(pipelineStore.Store));
var composer = new CardComposer(kanjStore, settings.Store);
var writer = new PipelineCardWriter(kanjStore, pipelineStore.Store, composer);
var composer = new CardComposer(kanjStore.Store, settings.Store);
var writer = new PipelineCardWriter(kanjStore.Store, pipelineStore.Store, composer);
var worker = new PipelineWorkerService(
pipelineStore.Store, settings.Store, rules, kanjStore, mlClient.Client, aiClassifier, processing, writer, fieldsParser);
pipelineStore.Store, settings.Store, rules, kanjStore.Store, mlClient.Client, aiClassifier, processing, writer, fieldsParser);
return new Context(worker, pipelineStore, kanjStore, settings, limits);
}
@@ -6,7 +6,6 @@ using Deal.Modules.Kanban.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Kanban.Application.Services;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Infrastructure;
@@ -21,7 +20,7 @@ public sealed class CardMoverTests
[Fact]
public async Task Move_ToStage_RoutesToStageMoveAndResetsReminder()
{
(CardMover mover, FakeKanjStore store) = Create();
(CardMover mover, TestKanjStore store) = Create();
store.SeedCard(new CardDto
{
Id = "c_1",
@@ -44,7 +43,7 @@ public sealed class CardMoverTests
[Fact]
public async Task Move_ToBoard_RoutesToDashboard()
{
(CardMover mover, FakeKanjStore store) = Create();
(CardMover mover, TestKanjStore store) = Create();
store.SeedBoard(new ContainerDto { Id = "b_py", Name = "Python" });
store.SeedCard(new CardDto { Id = "c_1", Col = KanbanColumns.Inbox, Content = new SourceContent { Text = "Нужен Python" } });
@@ -59,7 +58,7 @@ public sealed class CardMoverTests
[Fact]
public async Task Move_UnknownTarget_ReturnsDashboardInvalidTargetError()
{
(CardMover mover, FakeKanjStore store) = Create();
(CardMover mover, TestKanjStore store) = Create();
store.SeedCard(new CardDto { Id = "c_1", Col = KanbanColumns.Inbox, Content = new SourceContent { Text = "Текст" } });
CardMoveResultDto result = await mover.MoveAsync("c_1", "b_ghost", UserMove, CancellationToken.None);
@@ -79,10 +78,10 @@ public sealed class CardMoverTests
Assert.False(result.Exists);
}
private static (CardMover Mover, FakeKanjStore Store) Create()
private static (CardMover Mover, TestKanjStore Store) Create()
{
var store = new FakeKanjStore();
var cardsService = new CardsService(store, new TestSettingsStore().Store, new TestMlClient().Client, new TestFileStorage().Storage);
var store = new TestKanjStore();
var cardsService = new CardsService(store.Store, new TestSettingsStore().Store, new TestMlClient().Client, new TestFileStorage().Storage);
return (new CardMover(cardsService), store);
}
}
@@ -4,7 +4,6 @@ using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Modules.Kanban;
@@ -25,11 +24,11 @@ public sealed class AiClassifyContextBuilderTests
[Fact]
public async Task BuildFilterPrompt_FillsDomainAndKeywords()
{
TestSettingsStore settings = Context(out FakeKanjStore kanj);
TestSettingsStore settings = Context(out TestKanjStore kanj);
settings.Preload(SettingsKeys.AiFilterPrompt, Json("Страж. Сфера: {domain}. Маркеры: {keywords}."));
settings.Preload(SettingsKeys.DomainDescription, Json(Domain));
settings.Preload(SettingsKeys.DomainKeywords, Json(Keywords));
var builder = new AiClassifyContextBuilder(settings.Store, kanj);
var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store);
string prompt = await builder.BuildFilterPromptAsync(CancellationToken.None);
@@ -39,9 +38,9 @@ public sealed class AiClassifyContextBuilderTests
[Fact]
public async Task BuildFilterPrompt_NoDomain_UsesFallbackTextAndHint()
{
TestSettingsStore settings = Context(out FakeKanjStore kanj);
TestSettingsStore settings = Context(out TestKanjStore kanj);
settings.Preload(SettingsKeys.AiFilterPrompt, Json("[{domain}] {keywords}"));
var builder = new AiClassifyContextBuilder(settings.Store, kanj);
var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store);
string prompt = await builder.BuildFilterPromptAsync(CancellationToken.None);
@@ -53,12 +52,12 @@ public sealed class AiClassifyContextBuilderTests
[Fact]
public async Task BuildClassifySystemPrompt_AppendsCardPromptWhenSet()
{
TestSettingsStore settings = Context(out FakeKanjStore kanj);
TestSettingsStore settings = Context(out TestKanjStore kanj);
settings.Preload(SettingsKeys.AiPrompt, Json("Классифицируй {domain}."));
settings.Preload(SettingsKeys.CardPrompt, Json("Верни блок «О заявке» {keywords}."));
settings.Preload(SettingsKeys.DomainDescription, Json(Domain));
settings.Preload(SettingsKeys.DomainKeywords, Json(Keywords));
var builder = new AiClassifyContextBuilder(settings.Store, kanj);
var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store);
string prompt = await builder.BuildClassifySystemPromptAsync(CancellationToken.None);
@@ -70,8 +69,8 @@ public sealed class AiClassifyContextBuilderTests
[Fact]
public async Task BuildClassifyUserContext_EmptyBoards_ShowsNoBoardsPhrase()
{
TestSettingsStore settings = Context(out FakeKanjStore kanj);
var builder = new AiClassifyContextBuilder(settings.Store, kanj);
TestSettingsStore settings = Context(out TestKanjStore kanj);
var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store);
string context = await builder.BuildClassifyUserContextAsync("Нужен разработчик", CancellationToken.None);
@@ -83,7 +82,7 @@ public sealed class AiClassifyContextBuilderTests
[Fact]
public async Task BuildClassifyUserContext_ListsBoardsWithKeywordsAndRules()
{
TestSettingsStore settings = Context(out FakeKanjStore kanj);
TestSettingsStore settings = Context(out TestKanjStore kanj);
kanj.SeedBoard(new ContainerDto
{
Id = "b_py",
@@ -114,7 +113,7 @@ public sealed class AiClassifyContextBuilderTests
Order = 1,
});
kanj.SeedBoard(new ContainerDto { Id = "b_sug", Name = "Предложение", Suggested = true, Order = 2 });
var builder = new AiClassifyContextBuilder(settings.Store, kanj);
var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store);
string context = await builder.BuildClassifyUserContextAsync("Ищу Python-разработчика", CancellationToken.None);
@@ -129,10 +128,10 @@ public sealed class AiClassifyContextBuilderTests
[Fact]
public async Task BuildClassifyUserContext_IncludesMarkupExamplesNewestFirst()
{
TestSettingsStore settings = Context(out FakeKanjStore kanj);
TestSettingsStore settings = Context(out TestKanjStore kanj);
SeedCardWithMove(kanj, "l_1", "b_py", "Пример: middle python (старый)");
SeedCardWithMove(kanj, "l_2", "b_py", "Пример: senior go (свежий)");
var builder = new AiClassifyContextBuilder(settings.Store, kanj);
var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store);
string context = await builder.BuildClassifyUserContextAsync("Ищу разработчика", CancellationToken.None);
@@ -145,8 +144,8 @@ public sealed class AiClassifyContextBuilderTests
[Fact]
public async Task BuildClassifyUserContext_LongMessage_TruncatesTo5000CodePoints()
{
TestSettingsStore settings = Context(out FakeKanjStore kanj);
var builder = new AiClassifyContextBuilder(settings.Store, kanj);
TestSettingsStore settings = Context(out TestKanjStore kanj);
var builder = new AiClassifyContextBuilder(settings.Store, kanj.Store);
string text = new string('д', 6000);
string context = await builder.BuildClassifyUserContextAsync(text, CancellationToken.None);
@@ -159,9 +158,9 @@ public sealed class AiClassifyContextBuilderTests
// Создаёт контекст: пустое KV + пустой канбан-фейк.
// kanj: Канбан-фейк (доски/журнал примеров).
// Возвращает: KV-хранилище тенанта.
private static TestSettingsStore Context(out FakeKanjStore kanj)
private static TestSettingsStore Context(out TestKanjStore kanj)
{
kanj = new FakeKanjStore();
kanj = new TestKanjStore();
return new TestSettingsStore();
}
@@ -171,12 +170,12 @@ public sealed class AiClassifyContextBuilderTests
// board: Колонка-назначение (доска).
// text: Исходный текст карточки (source_msg).
private static void SeedCardWithMove(
FakeKanjStore kanj,
TestKanjStore kanj,
string cardId,
string board,
string text)
{
kanj.SeedCard(new CardDto { Id = cardId, Col = board, Content = new SourceContent { Text = text } });
kanj.AddMoveAsync(new CardMoveDto("lm_" + cardId, cardId, "move", "inbox", board), CancellationToken.None).GetAwaiter().GetResult();
kanj.Store.AddMoveAsync(new CardMoveDto("lm_" + cardId, cardId, "move", "inbox", board), CancellationToken.None).GetAwaiter().GetResult();
}
}
@@ -3,7 +3,6 @@ using Deal.Tests.Unit.Support;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Modules.Kanban;
@@ -19,7 +18,7 @@ public sealed class CardsServiceRemindersTests
[Fact]
public async Task Set_RemindersDisabled_Returns400TextAndWritesNothing()
{
(CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false);
(CardsService service, TestKanjStore store, _) = Create(remindersEnabled: false);
store.SeedCard(Card("c_1", stage: "hold", title: "Отложенный бот"));
CardResultDto result = await service.SetReminderAsync("c_1", NowMs() + DayMs, CancellationToken.None);
@@ -32,7 +31,7 @@ public sealed class CardsServiceRemindersTests
[Fact]
public async Task Set_Enabled_SetsReminderAndReturnsCard()
{
(CardsService service, FakeKanjStore store, _) = Create();
(CardsService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("c_1", stage: "hold", title: "Отложенный бот", updatedAtMs: 1));
long atMs = NowMs() + DayMs;
@@ -61,7 +60,7 @@ public sealed class CardsServiceRemindersTests
[Fact]
public async Task Set_StageNotHold_Allowed()
{
(CardsService service, FakeKanjStore store, _) = Create();
(CardsService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("c_1", stage: "work", title: "В работе"));
long atMs = NowMs() + DayMs;
@@ -76,7 +75,7 @@ public sealed class CardsServiceRemindersTests
[Fact]
public async Task Clear_WithReminder_ClearsItAndReturnsTrue()
{
(CardsService service, FakeKanjStore store, _) = Create();
(CardsService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) });
bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None);
@@ -98,7 +97,7 @@ public sealed class CardsServiceRemindersTests
[Fact]
public async Task Clear_RemindersDisabled_StillClears()
{
(CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false);
(CardsService service, TestKanjStore store, _) = Create(remindersEnabled: false);
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() + DayMs) });
bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None);
@@ -111,7 +110,7 @@ public sealed class CardsServiceRemindersTests
[Fact]
public async Task Snooze_MovesReminderToNowPlus24h()
{
(CardsService service, FakeKanjStore store, _) = Create();
(CardsService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) });
long beforeMs = NowMs();
@@ -136,7 +135,7 @@ public sealed class CardsServiceRemindersTests
[Fact]
public async Task Snooze_RemindersDisabled_StillSnoozes()
{
(CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false);
(CardsService service, TestKanjStore store, _) = Create(remindersEnabled: false);
store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) });
bool snoozed = await service.SnoozeReminderAsync("c_1", CancellationToken.None);
@@ -149,7 +148,7 @@ public sealed class CardsServiceRemindersTests
[Fact]
public async Task CheckDue_Disabled_ClearsExpiredAndReturnsEmpty()
{
(CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false);
(CardsService service, TestKanjStore store, _) = Create(remindersEnabled: false);
long pastMs = NowMs() - 1;
store.SeedCard(Card("c_hold_past", stage: "hold") with { Reminder = new CardReminderDto(pastMs) });
store.SeedCard(Card("c_work_past", stage: "work") with { Reminder = new CardReminderDto(pastMs) });
@@ -167,7 +166,7 @@ public sealed class CardsServiceRemindersTests
[Fact]
public async Task CheckDue_Enabled_ReturnsDueAndMarksFired()
{
(CardsService service, FakeKanjStore store, _) = Create();
(CardsService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("c_d1", stage: "hold", title: "Ранний") with { Reminder = new CardReminderDto(NowMs() - 2 * DayMs) });
store.SeedCard(Card("c_d2", stage: "hold", title: "Поздний") with { Reminder = new CardReminderDto(NowMs() - 1) });
store.SeedCard(Card("c_future", stage: "hold", title: "Будущий") with { Reminder = new CardReminderDto(NowMs() + DayMs) });
@@ -181,21 +180,21 @@ public sealed class CardsServiceRemindersTests
due.Select(item => (item.Id, item.Title)));
Assert.All(due, item => Assert.Equal("hold", item.ContainerId));
// «Выстрелившие» помечены fired: повторная выборка due пуста (признак держит строка БД).
Assert.Empty(await store.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None));
Assert.Empty(await store.Store.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None));
}
// ─── Хелперы ──────────────────────────────────────────────────────────
private static (CardsService Service, FakeKanjStore Store, TestSettingsStore Settings) Create(bool remindersEnabled = true)
private static (CardsService Service, TestKanjStore Store, TestSettingsStore Settings) Create(bool remindersEnabled = true)
{
var store = new FakeKanjStore();
var store = new TestKanjStore();
var settings = new TestSettingsStore();
if (!remindersEnabled)
{
settings.Preload(SettingsKeys.RemindersEnabled, "false");
}
return (new CardsService(store, settings.Store, new TestMlClient().Client, new TestFileStorage().Storage), store, settings);
return (new CardsService(store.Store, settings.Store, new TestMlClient().Client, new TestFileStorage().Storage), store, settings);
}
// Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем.
@@ -5,7 +5,6 @@ using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Pipeline.Application.Models;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
@@ -20,14 +19,14 @@ public sealed class MlReviewServiceTests
private static MlReviewService Create(
TestPipelineStore pipeline,
FakeKanjStore kanj,
TestKanjStore kanj,
TestMlClient ml,
out CardsService cards)
{
var settings = new TestSettingsStore();
cards = new CardsService(kanj, settings.Store, ml.Client, new TestFileStorage().Storage);
cards = new CardsService(kanj.Store, settings.Store, ml.Client, new TestFileStorage().Storage);
var processing = new PipelineProcessingService(pipeline.Store, ml.Client, new PipelineIngestService(pipeline.Store));
return new MlReviewService(pipeline.Store, kanj, cards, processing, ml.Client);
return new MlReviewService(pipeline.Store, kanj.Store, cards, processing, ml.Client);
}
private static QueueItemDto QueueRow(
@@ -76,7 +75,7 @@ public sealed class MlReviewServiceTests
var pipeline = new TestPipelineStore();
pipeline.SeedQueue(QueueRow(101, "из очереди"));
pipeline.SeedRejected(RejectedRow(102, "из отсева"));
var kanj = new FakeKanjStore();
var kanj = new TestKanjStore();
kanj.SeedCard(Card(103, "из карточки"));
MlReviewService service = Create(pipeline, kanj, new TestMlClient(), out _);
@@ -97,7 +96,7 @@ public sealed class MlReviewServiceTests
var pipeline = new TestPipelineStore();
pipeline.SeedQueue(QueueRow(101, "нужный", Dialog));
pipeline.SeedQueue(QueueRow(201, "другой", "d_2"));
MlReviewService service = Create(pipeline, new FakeKanjStore(), new TestMlClient(), out _);
MlReviewService service = Create(pipeline, new TestKanjStore(), new TestMlClient(), out _);
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None);
@@ -111,7 +110,7 @@ public sealed class MlReviewServiceTests
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 TestMlClient(), out _);
MlReviewService service = Create(pipeline, new TestKanjStore(), new TestMlClient(), out _);
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(null, 10, CancellationToken.None);
@@ -127,7 +126,7 @@ public sealed class MlReviewServiceTests
pipeline.SeedQueue(QueueRow(100 + i, $"текст {i}"));
}
MlReviewService service = Create(pipeline, new FakeKanjStore(), new TestMlClient(), out _);
MlReviewService service = Create(pipeline, new TestKanjStore(), new TestMlClient(), out _);
IReadOnlyList<MlCandidateDto> items = await service.CandidatesAsync(Dialog, 2, CancellationToken.None);
@@ -140,7 +139,7 @@ public sealed class MlReviewServiceTests
var pipeline = new TestPipelineStore();
pipeline.SeedQueue(QueueRow(101, "текст"));
var ml = new TestMlClient();
MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _);
MlReviewService service = Create(pipeline, new TestKanjStore(), ml, out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSkip, CancellationToken.None);
@@ -154,7 +153,7 @@ public sealed class MlReviewServiceTests
[Fact]
public async Task Apply_Spam_WithCard_TrashesAndLearns()
{
var kanj = new FakeKanjStore();
var kanj = new TestKanjStore();
kanj.SeedCard(Card(101, "спамный текст"));
var ml = new TestMlClient();
MlReviewService service = Create(new TestPipelineStore(), kanj, ml, out _);
@@ -175,7 +174,7 @@ public sealed class MlReviewServiceTests
var pipeline = new TestPipelineStore();
pipeline.SeedQueue(QueueRow(101, "рекламный текст"));
var ml = new TestMlClient();
MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _);
MlReviewService service = Create(pipeline, new TestKanjStore(), ml, out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None);
@@ -192,7 +191,7 @@ public sealed class MlReviewServiceTests
[Fact]
public async Task Apply_Board_MovesCardAndLearns()
{
var kanj = new FakeKanjStore();
var kanj = new TestKanjStore();
kanj.SeedBoard(new ContainerDto
{
Id = "b_py",
@@ -216,7 +215,7 @@ public sealed class MlReviewServiceTests
[Fact]
public async Task Apply_UnknownBoard_ReturnsError()
{
var kanj = new FakeKanjStore();
var kanj = new TestKanjStore();
kanj.SeedCard(Card(101, "текст"));
MlReviewService service = Create(new TestPipelineStore(), kanj, new TestMlClient(), out _);
@@ -232,7 +231,7 @@ public sealed class MlReviewServiceTests
{
var pipeline = new TestPipelineStore();
pipeline.SeedQueue(QueueRow(101, "текст"));
MlReviewService service = Create(pipeline, new FakeKanjStore(), new TestMlClient(), out _);
MlReviewService service = Create(pipeline, new TestKanjStore(), new TestMlClient(), out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "что-то", CancellationToken.None);
@@ -244,7 +243,7 @@ public sealed class MlReviewServiceTests
[Fact]
public async Task Apply_MessageNotFound_ReturnsNull()
{
MlReviewService service = Create(new TestPipelineStore(), new FakeKanjStore(), new TestMlClient(), out _);
MlReviewService service = Create(new TestPipelineStore(), new TestKanjStore(), new TestMlClient(), out _);
MlApplyResult? result = await service.ApplyAsync(Dialog, 999, MlReviewService.ActionSpam, CancellationToken.None);
@@ -2,7 +2,6 @@ using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Modules.Kanban;
@@ -16,7 +15,7 @@ public sealed class StorageTickServiceTests
[Fact]
public async Task Tick_DefaultSettings_ArchivesExpiredBoardAndInboxCards()
{
(StorageTickService service, FakeKanjStore store, _) = Create();
(StorageTickService service, TestKanjStore store, _) = Create();
store.SeedBoard(Board("b_1"));
store.SeedCard(Card("l_board_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(16))));
store.SeedCard(Card("l_board_young", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(13)))); // < 14 дн. — не кандидат
@@ -48,7 +47,7 @@ public sealed class StorageTickServiceTests
[Fact]
public async Task Tick_AutoArchiveSettingFalse_KeepsExpiredCardsInPlace()
{
(StorageTickService service, FakeKanjStore store, TestSettingsStore settings) = Create();
(StorageTickService service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.AutoArchive, "false");
store.SeedBoard(Board("b_1"));
store.SeedCard(Card("l_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(30))));
@@ -63,7 +62,7 @@ public sealed class StorageTickServiceTests
[Fact]
public async Task Tick_ArchiveAfterDays30_ArchivesOnlyCardsOlderThanConfiguredMonth()
{
(StorageTickService service, FakeKanjStore store, TestSettingsStore settings) = Create();
(StorageTickService service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.ArchiveAfterDays, "30");
store.SeedBoard(Board("b_1"));
store.SeedCard(Card("l_older_month", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(32))));
@@ -79,7 +78,7 @@ public sealed class StorageTickServiceTests
[Fact]
public async Task Tick_ArchiveAfterDays1_ArchivesTwoDayOldCard()
{
(StorageTickService service, FakeKanjStore store, TestSettingsStore settings) = Create();
(StorageTickService service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.ArchiveAfterDays, "1");
store.SeedCard(Card("l_inbox_old", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromDays(2))));
@@ -92,7 +91,7 @@ public sealed class StorageTickServiceTests
[Fact]
public async Task Tick_SecondRun_DoesNotReArchiveNorPurgeJustArchivedCards()
{
(StorageTickService service, FakeKanjStore store, _) = Create();
(StorageTickService service, TestKanjStore store, _) = Create();
store.SeedBoard(Board("b_1"));
store.SeedCard(Card("l_old", "b_1", ReceivedAtMsAgo(TimeSpan.FromDays(20))));
@@ -110,7 +109,7 @@ public sealed class StorageTickServiceTests
[Fact]
public async Task Tick_PurgesOnlyArchiveCardsWithArchivedAtOlderThanArchiveClearDays()
{
(StorageTickService service, FakeKanjStore store, _) = Create();
(StorageTickService service, TestKanjStore store, _) = Create();
DateTimeOffset now = DateTimeOffset.UtcNow;
store.SeedCard(Card("l_expired", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(30))));
store.SetArchivedAt("l_expired", now.AddDays(-100)); // старше 90 дн.
@@ -130,7 +129,7 @@ public sealed class StorageTickServiceTests
[Fact]
public async Task Tick_ArchiveClearDaysFromSettings_ControlsArchivePurgeBoundary()
{
(StorageTickService service, FakeKanjStore store, TestSettingsStore settings) = Create();
(StorageTickService service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.ArchiveClearDays, "30");
DateTimeOffset now = DateTimeOffset.UtcNow;
store.SeedCard(Card("l_expired", KanbanColumns.Archive, ReceivedAtMsAgo(TimeSpan.FromDays(10))));
@@ -149,7 +148,7 @@ public sealed class StorageTickServiceTests
[Fact]
public async Task Tick_PurgesOnlyTrashCardsWithReceivedAtOlderThanTrashClearDays()
{
(StorageTickService service, FakeKanjStore store, _) = Create();
(StorageTickService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("l_old", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(10)))); // старше 7 дн.
store.SeedCard(Card("l_fresh", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromHours(6))));
@@ -166,7 +165,7 @@ public sealed class StorageTickServiceTests
[Fact]
public async Task Tick_TrashClearDaysFromSettings_ControlsTrashPurgeBoundary()
{
(StorageTickService service, FakeKanjStore store, TestSettingsStore settings) = Create();
(StorageTickService service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.TrashClearDays, "3");
store.SeedCard(Card("l_old", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(5)))); // старше 3 дн.
store.SeedCard(Card("l_fresh", KanbanColumns.Trash, ReceivedAtMsAgo(TimeSpan.FromDays(1))));
@@ -195,11 +194,11 @@ public sealed class StorageTickServiceTests
// ─── Хелперы ────────────────────────────────────────────────────────────
private static (StorageTickService Service, FakeKanjStore Store, TestSettingsStore Settings) Create()
private static (StorageTickService Service, TestKanjStore Store, TestSettingsStore Settings) Create()
{
var store = new FakeKanjStore();
var store = new TestKanjStore();
var settings = new TestSettingsStore();
return (new StorageTickService(store, settings.Store), store, settings);
return (new StorageTickService(store.Store, settings.Store), store, settings);
}
// Доска-колонка минимально (id/имя достаточно для списка кандидатов фейка).
@@ -216,13 +215,13 @@ public sealed class StorageTickServiceTests
private static long ReceivedAtMsAgo(TimeSpan age) => DateTimeOffset.UtcNow.Subtract(age).ToUnixTimeMilliseconds();
// Колонка карточки в фейке после тика.
private static string ColOf(FakeKanjStore store, string cardId) =>
private static string ColOf(TestKanjStore store, string cardId) =>
store.CardDtos.Single(card => card.Id == cardId).Col;
// Флаг is_new карточки после тика.
private static bool IsNewOf(FakeKanjStore store, string cardId) =>
private static bool IsNewOf(TestKanjStore store, string cardId) =>
store.CardDtos.Single(card => card.Id == cardId).IsNew;
private static IReadOnlyList<MatchHitDto> MatchHitsOf(FakeKanjStore store, string cardId) =>
private static IReadOnlyList<MatchHitDto> MatchHitsOf(TestKanjStore store, string cardId) =>
store.CardDtos.Single(card => card.Id == cardId).MatchHits;
}
@@ -11,7 +11,6 @@ using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Pipeline.Application.Services;
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;
@@ -31,7 +30,7 @@ public sealed class AdminTickOrchestratorTests
private sealed record Context(
AdminTickOrchestrator Orchestrator,
TestPipelineStore PipelineStore,
FakeKanjStore KanjStore,
TestKanjStore KanjStore,
TestSettingsStore Settings,
TestAiClassifier AiClassifier,
PipelinePumpGate PumpGate,
@@ -160,7 +159,7 @@ public sealed class AdminTickOrchestratorTests
Assert.Equal("hold", payload.RootElement.GetProperty("containerId").GetString());
// «Выстрелившее» помечено fired: повторная выборка due пуста (признак держит строка БД).
Assert.Empty(await ctx.KanjStore.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None));
Assert.Empty(await ctx.KanjStore.Store.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None));
}
[Fact]
@@ -211,24 +210,24 @@ public sealed class AdminTickOrchestratorTests
.Returns<IReadOnlyList<QueueItemDto>>(_ =>
throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync)."));
}
var kanjStore = new FakeKanjStore();
var kanjStore = new TestKanjStore();
var mlClient = new TestMlClient();
var aiClassifier = new TestAiClassifier();
var rules = new IncomingRules(settings.Store);
var fieldsParser = new LocalFieldsParser(settings.Store);
var ingest = new PipelineIngestService(store.Store);
var processing = new PipelineProcessingService(store.Store, mlClient.Client, ingest);
var composer = new CardComposer(kanjStore, settings.Store);
var writer = new PipelineCardWriter(kanjStore, store.Store, composer);
var composer = new CardComposer(kanjStore.Store, settings.Store);
var writer = new PipelineCardWriter(kanjStore.Store, store.Store, composer);
var worker = new PipelineWorkerService(
store.Store, settings.Store, rules, kanjStore, mlClient.Client, aiClassifier.Classifier, processing, writer, fieldsParser);
store.Store, settings.Store, rules, kanjStore.Store, mlClient.Client, aiClassifier.Classifier, processing, writer, fieldsParser);
var broker = new SseBroker();
var tickService = new StorageTickService(kanjStore, settings.Store);
var tickService = new StorageTickService(kanjStore.Store, settings.Store);
var toastPublisher = new StorageToastPublisher(broker);
var pumpGate = new PipelinePumpGate();
FakeKanjStore reminderStore = withThrowingReminderCheck ? new ThrowingDueKanjStore() : kanjStore;
var cardsService = new CardsService(reminderStore, settings.Store, mlClient.Client, new TestFileStorage().Storage);
TestKanjStore reminderStore = withThrowingReminderCheck ? new TestKanjStore(throwOnDueReminders: true) : kanjStore;
var cardsService = new CardsService(reminderStore.Store, settings.Store, mlClient.Client, new TestFileStorage().Storage);
var orchestrator = new AdminTickOrchestrator(
tickService, processing, worker, cardsService, toastPublisher, broker, pumpGate,
NullLogger<AdminTickOrchestrator>.Instance);
@@ -237,16 +236,7 @@ public sealed class AdminTickOrchestratorTests
broker.Subscribe(TenantA));
}
// Хранилище карточек со сбоем выборки due-напоминаний: ListDueRemindersAsync бросает (сценарий
// «БД/схема недоступны» на проверке напоминаний — тик продолжается, reminders ответа пуст).
private sealed class ThrowingDueKanjStore : FakeKanjStore
{
/// <inheritdoc />
public override Task<IReadOnlyList<CardReminderDueDto>> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct)
{
throw new InvalidOperationException("Тестовый сбой выборки due-напоминаний (ListDueRemindersAsync).");
}
}
// Сбой выборки due-напоминаний задаётся флагом throwOnDueReminders в TestKanjStore.
// Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем.
private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
@@ -9,7 +9,6 @@ using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support;
@@ -22,7 +21,7 @@ public sealed class CardReclassifierTests
// Контекст теста: сервис поверх in-memory фейков.
private sealed record Context(
CardReclassifier Reclassifier,
FakeKanjStore Store,
TestKanjStore Store,
TestSettingsStore Settings,
TestMlClient MlClient,
TestAiClassifier AiClassifier,
@@ -33,15 +32,15 @@ public sealed class CardReclassifierTests
private static Context CreateContext()
{
var settings = new TestSettingsStore();
var store = new FakeKanjStore();
var store = new TestKanjStore();
var mlClient = new TestMlClient();
var aiClassifier = new TestAiClassifier();
var fieldsParser = new LocalFieldsParser(settings.Store);
var composer = new CardComposer(store, settings.Store);
var cardsService = new CardsService(store, settings.Store, mlClient.Client, new TestFileStorage().Storage);
var composer = new CardComposer(store.Store, settings.Store);
var cardsService = new CardsService(store.Store, settings.Store, mlClient.Client, new TestFileStorage().Storage);
var gate = new ReclassifyGate();
var reclassifier = new CardReclassifier(
store, settings.Store, aiClassifier.Classifier, fieldsParser, composer, cardsService, mlClient.Client, gate);
store.Store, settings.Store, aiClassifier.Classifier, fieldsParser, composer, cardsService, mlClient.Client, gate);
return new Context(reclassifier, store, settings, mlClient, aiClassifier, gate);
}
@@ -2,7 +2,6 @@ using System.Text;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support;
@@ -16,7 +15,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Add_ImageMime_DetectsKindByMimeWritesObjectAndMeta()
{
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
store.SeedCard(Card("c_1", updatedAtMs: 1));
byte[] content = Encoding.UTF8.GetBytes("данные-картинки");
using MemoryStream stream = new(content);
@@ -42,7 +41,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Add_PdfExtensionWithoutMime_DetectsKindByExtension()
{
(CardsService service, FakeKanjStore store, _) = Create();
(CardsService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("c_1"));
CardFileDto? entry = await service.AddFileAsync(
@@ -57,7 +56,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Add_TwoFiles_AppendsPreservingOrderAndBothObjects()
{
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
store.SeedCard(Card("c_1"));
CardFileDto? first = await service.AddFileAsync(
@@ -76,7 +75,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Add_CardMissing_ReturnsNullAndDoesNotWriteObject()
{
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
CardFileDto? entry = await service.AddFileAsync(
"c_missing", "photo.png", "image/png", new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None);
@@ -89,7 +88,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Add_EmptyOrWhitespaceFileName_DefaultsToPrototypeFile()
{
(CardsService service, FakeKanjStore store, _) = Create();
(CardsService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("c_1"));
CardFileDto? entry = await service.AddFileAsync(
@@ -105,7 +104,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Add_FileNameWithPathAndQuoteChars_SanitizesObjectKeyButKeepsMetaName()
{
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
store.SeedCard(Card("c_1"));
string rawName = "..\\файл\"отчёта v2.pdf";
@@ -123,7 +122,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Add_StreamPositionNotZero_StoresWholeContent()
{
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
store.SeedCard(Card("c_1"));
byte[] content = Encoding.UTF8.GetBytes("полное-содержимое-файла");
using MemoryStream stream = new(content) { Position = 5 }; // эндпоинт мог прочитать поток раньше
@@ -141,7 +140,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task GetEntry_ExistingFile_ReturnsEntryMeta()
{
(CardsService service, FakeKanjStore store, _) = Create();
(CardsService service, TestKanjStore store, _) = Create();
var file = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000000_tz.pdf");
store.SeedCard(Card("c_1") with { Files = new[] { file } });
@@ -164,7 +163,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task GetEntry_FileNotInMetadata_ReturnsNull()
{
(CardsService service, FakeKanjStore store, _) = Create();
(CardsService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("c_1")
with { Files = new[] { new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "k") } });
@@ -177,7 +176,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Remove_Existing_DeletesObjectRemovesMetaAndReturnsCard()
{
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
var first = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000001_tz.pdf");
var second = new CardFileDto("pf_2", "photo.png", 200, "image", "Изображение", "projects/c_1/1710000000002_photo.png");
store.SeedCard(Card("c_1", updatedAtMs: 1) with { Files = new[] { first, second } });
@@ -199,7 +198,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Remove_UnknownFileId_ReturnsCardUnchangedWithoutStorageDelete()
{
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
var file = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000001_tz.pdf");
store.SeedCard(Card("c_1") with { Files = new[] { file } });
@@ -214,7 +213,7 @@ public sealed class CardsServiceFilesTests
[Fact]
public async Task Remove_EntryWithEmptyObjectKey_SkipsStorageDelete()
{
(CardsService service, FakeKanjStore store, TestFileStorage storage) = Create();
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
store.SeedCard(Card("c_1")
with { Files = new[] { new CardFileDto("pf_mock", "meta-only.pdf", 10, "document", "Документ", ObjectKey: string.Empty) } });
@@ -238,11 +237,11 @@ public sealed class CardsServiceFilesTests
// ─── Хелперы ──────────────────────────────────────────────────────────
private static (CardsService Service, FakeKanjStore Store, TestFileStorage Storage) Create()
private static (CardsService Service, TestKanjStore Store, TestFileStorage Storage) Create()
{
var store = new FakeKanjStore();
var store = new TestKanjStore();
var storage = new TestFileStorage();
return (new CardsService(store, new TestSettingsStore().Store, new TestMlClient().Client, storage.Storage), store, storage);
return (new CardsService(store.Store, new TestSettingsStore().Store, new TestMlClient().Client, storage.Storage), store, storage);
}
// Карточка с полями по умолчанию (planned, CreatedAtMs=1, UpdatedAtMs=1; Files пуст).
@@ -2,7 +2,6 @@ using System.Text.Json;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support;
@@ -16,7 +15,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task List_NoStage_ReturnsCardsOrderedByUpdatedAtDesc()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", updatedAtMs: 100));
store.SeedCard(Card("c_2", updatedAtMs: 300));
store.SeedCard(Card("c_3", stage: "work", updatedAtMs: 200));
@@ -29,7 +28,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task List_ByStage_ReturnsOnlyStageCards()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1"));
store.SeedCard(Card("c_2", stage: "work"));
@@ -53,7 +52,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task CreateLocal_WithStage_TrimsTitleAndWritesCreatedLocalHistory()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
CardDto card = await service.CreateLocalCardAsync(
new CardLocalCreateDto(Title: " Задача на бота ", ContainerId: "work"),
@@ -102,7 +101,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task TakeCard_CardMissing_ReturnsNullAndCreatesNothing()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
CardDto? card = await service.TakeCardAsync("c_missing", CancellationToken.None);
@@ -113,7 +112,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task TakeCard_MovesCardToPlannedKeepingFields()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card(
"c_1",
stage: "inbox",
@@ -150,7 +149,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task TakeCard_AlreadyInStage_ReturnsCardUnchanged()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", stage: "work", title: "Уже взята"));
CardDto? card = await service.TakeCardAsync("c_1", CancellationToken.None);
@@ -167,7 +166,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task Patch_PresentKeys_UpdateFieldsAndBumpUpdatedAt()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card(
"c_1",
title: "Старый заголовок",
@@ -198,7 +197,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task Patch_BudgetNull_ClearsBudget()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "С бюджетом", updatedAtMs: 1)
with { Budget = new CardBudgetDto(1000, 2000, "USD") });
@@ -213,7 +212,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task Patch_StackNull_ClearsStack()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "С стеком", updatedAtMs: 1)
with { Stack = new[] { "Python" } });
@@ -226,7 +225,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task Patch_UnknownKeys_AreIgnored()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Старый"));
CardDto? card = await service.PatchCardAsync(
@@ -242,7 +241,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task Patch_NullTextKey_IsIgnored()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Старый", summary: "Описание", updatedAtMs: 1));
CardDto? card = await service.PatchCardAsync(
@@ -272,7 +271,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task Move_TwoMoves_AppendHistoryResetReminderAndBumpUpdatedAt()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
long seededAt = 5_000;
CardDto before = Card("c_1", stage: "hold", title: "Отложенная", updatedAtMs: seededAt)
with
@@ -310,7 +309,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task Move_UnknownStage_Returns400ErrorAndLeavesCardUntouched()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", stage: "planned"));
CardResultDto result = await service.MoveStageCardAsync("c_1", "stuck", CancellationToken.None);
@@ -337,7 +336,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task ClearRejected_RemovesOnlyRejectedAndReturnsCount()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", stage: "rejected"));
store.SeedCard(Card("c_2", stage: "rejected"));
store.SeedCard(Card("c_3", stage: "finished"));
@@ -363,7 +362,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task AddComment_Valid_AppendsTrimmedCommentWithWireForm()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1));
AddCommentResultDto result = await service.AddCommentAsync("c_1", " Перезвонить завтра ", CancellationToken.None);
@@ -381,7 +380,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task AddComment_TwoComments_AppendsPreservingOrder()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Карточка")
with { Comments = new[] { new CardCommentDto("cm_1", "Вы", "Первый", "5 мин") } });
@@ -396,7 +395,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task AddComment_EmptyOrWhitespaceText_Returns400DetailAndWritesNothing()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Карточка"));
AddCommentResultDto empty = await service.AddCommentAsync("c_1", string.Empty, CancellationToken.None);
@@ -411,7 +410,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task AddComment_CardMissing_ReturnsNullComments()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
AddCommentResultDto result = await service.AddCommentAsync("c_missing", "Текст", CancellationToken.None);
@@ -424,7 +423,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task AddLink_NoScheme_PrefixesHttpsAndDefaultsNameToUrl()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1)
with { Links = new[] { new CardLinkDto("pl_1", "Сайт", "https://example.com") } });
@@ -444,7 +443,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task AddLink_HasHttpScheme_KeepsSchemeAndUsesTrimmedName()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Карточка"));
CardResultDto result = await service.AddLinkAsync("c_1", " Сайт ", "http://site.ru/abc", CancellationToken.None);
@@ -458,7 +457,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task AddLink_EmptyUrl_Returns400DetailAndWritesNothing()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Карточка"));
CardResultDto result = await service.AddLinkAsync("c_1", "Имя", " ", CancellationToken.None);
@@ -471,7 +470,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task AddLink_CardMissing_ReturnsNullCardBeforeUrlValidation()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
CardResultDto result = await service.AddLinkAsync("c_missing", string.Empty, "example.com", CancellationToken.None);
@@ -483,7 +482,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task RemoveLink_ById_RemovesOnlyTargetAndReturnsCard()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1)
with
{
@@ -507,7 +506,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task RemoveLink_UnknownLinkId_LeavesLinksUnchangedWithoutError()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
store.SeedCard(Card("c_1", title: "Карточка")
with { Links = new[] { new CardLinkDto("pl_1", "Сайт", "https://a.b") } });
@@ -520,7 +519,7 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task RemoveLink_CardMissing_ReturnsNullCard()
{
(CardsService service, FakeKanjStore store, _, _) = Create();
(CardsService service, TestKanjStore store, _, _) = Create();
CardResultDto result = await service.RemoveLinkAsync("c_missing", "pl_1", CancellationToken.None);
@@ -531,12 +530,12 @@ public sealed class CardsServiceSelectedTests
// ─── Хелперы ──────────────────────────────────────────────────────────
private static (CardsService Service, FakeKanjStore Store, TestSettingsStore Settings, TestMlClient Ml) Create()
private static (CardsService Service, TestKanjStore Store, TestSettingsStore Settings, TestMlClient Ml) Create()
{
var store = new FakeKanjStore();
var store = new TestKanjStore();
var settings = new TestSettingsStore();
var ml = new TestMlClient();
return (new CardsService(store, settings.Store, ml.Client, new TestFileStorage().Storage), store, settings, ml);
return (new CardsService(store.Store, settings.Store, ml.Client, new TestFileStorage().Storage), store, settings, ml);
}
// Тело PATCH из пар «ключ → значение» (presence = наличие пары; null — явный JSON-null).
@@ -1,7 +1,6 @@
using System.Text.Json;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support;
@@ -29,7 +28,7 @@ public sealed class ContainersServiceTests
[Fact]
public async Task Create_ExistingContainers_OrderIsMaxPlusOne()
{
(ContainersService service, FakeKanjStore store, _) = Create();
(ContainersService service, TestKanjStore store, _) = Create();
store.SeedBoard(Container("b_2", 2));
store.SeedBoard(Container("b_5", 5));
@@ -43,7 +42,7 @@ public sealed class ContainersServiceTests
[Fact]
public async Task Create_OrderEight_CyclesPaletteToFirst()
{
(ContainersService service, FakeKanjStore store, _) = Create();
(ContainersService service, TestKanjStore store, _) = Create();
store.SeedBoard(Container("b_7", 7));
ContainerDto container = await service.CreateAsync(new ContainerCreateDto("Middle"), CancellationToken.None);
@@ -157,7 +156,7 @@ public sealed class ContainersServiceTests
[Fact]
public async Task List_FillsCountsFromCards()
{
(ContainersService service, FakeKanjStore store, _) = Create();
(ContainersService service, TestKanjStore store, _) = Create();
ContainerDto container = await service.CreateAsync(new ContainerCreateDto("Middle"), CancellationToken.None);
store.SeedCard(new CardDto { Id = "c_1", Col = container.Id, IsNew = true });
store.SeedCard(new CardDto { Id = "c_2", Col = container.Id, IsNew = true });
@@ -283,16 +282,16 @@ public sealed class ContainersServiceTests
[Fact]
public async Task Reorder_AssignsOrdersInGivenOrder()
{
(ContainersService service, FakeKanjStore store, _) = Create();
(ContainersService service, TestKanjStore store, _) = Create();
store.SeedBoard(Container("b_1", 0));
store.SeedBoard(Container("b_2", 1));
store.SeedBoard(Container("b_3", 2));
await service.ReorderAsync(ContainerSpaces.Dashboard, ["b_3", "b_1", "b_2"], CancellationToken.None);
Assert.Equal(0, (await store.GetContainerAsync("b_3", CancellationToken.None))!.Order);
Assert.Equal(1, (await store.GetContainerAsync("b_1", CancellationToken.None))!.Order);
Assert.Equal(2, (await store.GetContainerAsync("b_2", CancellationToken.None))!.Order);
Assert.Equal(0, (await store.Store.GetContainerAsync("b_3", CancellationToken.None))!.Order);
Assert.Equal(1, (await store.Store.GetContainerAsync("b_1", CancellationToken.None))!.Order);
Assert.Equal(2, (await store.Store.GetContainerAsync("b_2", CancellationToken.None))!.Order);
}
// ─── Удаление контейнера ──────────────────────────────────────────────
@@ -300,7 +299,7 @@ public sealed class ContainersServiceTests
[Fact]
public async Task Delete_MovesCardsToInbox_ReturnsMovedCount()
{
(ContainersService service, FakeKanjStore store, _) = Create();
(ContainersService service, TestKanjStore store, _) = Create();
ContainerDto container = await service.CreateAsync(new ContainerCreateDto("Middle"), CancellationToken.None);
store.AddCard("c_1", container.Id);
store.AddCard("c_2", container.Id);
@@ -309,7 +308,7 @@ public sealed class ContainersServiceTests
int moved = await service.DeleteAsync(container.Id, CancellationToken.None);
Assert.Equal(2, moved);
Assert.Null(await store.GetContainerAsync(container.Id, CancellationToken.None)); // контейнер удалён
Assert.Null(await store.Store.GetContainerAsync(container.Id, CancellationToken.None)); // контейнер удалён
Assert.Contains(store.Cards, card => card.CardId == "c_1" && card.Col == "inbox" && card.IsNew);
Assert.Contains(store.Cards, card => card.CardId == "c_2" && card.Col == "inbox" && card.IsNew);
Assert.Contains(store.Cards, card => card.CardId == "c_inbox" && card.Col == "inbox"); // inbox не тронут
@@ -418,11 +417,11 @@ public sealed class ContainersServiceTests
// ─── Хелперы ──────────────────────────────────────────────────────────
private static (ContainersService Service, FakeKanjStore Store, TestSettingsStore Settings) Create()
private static (ContainersService Service, TestKanjStore Store, TestSettingsStore Settings) Create()
{
var store = new FakeKanjStore();
var store = new TestKanjStore();
var settings = new TestSettingsStore();
return (new ContainersService(store, settings.Store), store, settings);
return (new ContainersService(store.Store, settings.Store), store, settings);
}
// Патч только с нужными полями: остальные параметры null = «не менять».
@@ -5,7 +5,6 @@ using Deal.Modules.Settings.Application.Abstractions;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Settings.Application.Services;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support;
@@ -20,7 +19,7 @@ public sealed class ConversionRecomputerTests
[Fact]
public async Task Recompute_MockRates_ConvertsUsdBudgetToRub()
{
(ConversionRecomputer service, FakeKanjStore store, TestSettingsStore settings) = Create();
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
store.SeedCard(Card("l_inbox", KanbanColumns.Inbox, from: 50, to: 200, cur: "USD"));
@@ -35,7 +34,7 @@ public sealed class ConversionRecomputerTests
[Fact]
public async Task Recompute_TargetCurrencyFromSettings_ConvertsToConfiguredCurrency()
{
(ConversionRecomputer service, FakeKanjStore store, TestSettingsStore settings) = Create();
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
settings.Preload(SettingsKeys.TargetCurrency, "\"EUR\""); // PATCH пишет Upper (ApplyStringKey)
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -50,7 +49,7 @@ public sealed class ConversionRecomputerTests
[Fact]
public async Task Recompute_NoTargetCurrencySetting_DefaultsToRub()
{
(ConversionRecomputer service, FakeKanjStore store, TestSettingsStore settings) = Create();
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -63,7 +62,7 @@ public sealed class ConversionRecomputerTests
[Fact]
public async Task Recompute_UsdtBudget_TreatedAsUsdRate()
{
(ConversionRecomputer service, FakeKanjStore store, TestSettingsStore settings) = Create();
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
var rates = new Dictionary<string, double> { ["RUB"] = 1.0, ["USD"] = 100.0, ["USDT"] = 90.0 };
settings.Preload(SettingsKeys.RatesCache, CacheJson(rates));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USDT"));
@@ -79,7 +78,7 @@ public sealed class ConversionRecomputerTests
[Fact]
public async Task Recompute_BudgetWithoutUpperBound_UsesLowerBoundForConvTo()
{
(ConversionRecomputer service, FakeKanjStore store, TestSettingsStore settings) = Create();
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: null, cur: "USD"));
@@ -94,7 +93,7 @@ public sealed class ConversionRecomputerTests
[Fact]
public async Task Recompute_ConversionOff_ReturnsZeroAndKeepsCardsUntouched()
{
(ConversionRecomputer service, FakeKanjStore store, TestSettingsStore settings) = Create();
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.ConversionOn, "false");
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -110,7 +109,7 @@ public sealed class ConversionRecomputerTests
[Fact]
public async Task Recompute_ExcludesArchiveAndTrashCards()
{
(ConversionRecomputer service, FakeKanjStore store, TestSettingsStore settings) = Create();
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
store.SeedCard(Card("l_archive", KanbanColumns.Archive, from: 100, to: 100, cur: "USD"));
@@ -129,7 +128,7 @@ public sealed class ConversionRecomputerTests
[Fact]
public async Task Recompute_MissingCurrencyInRates_SkipsCardKeepingOldConversion()
{
(ConversionRecomputer service, FakeKanjStore store, TestSettingsStore settings) = Create();
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, CacheJson(new Dictionary<string, double> { ["RUB"] = 1.0, ["USD"] = 92.5 }));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "XXX") with
{
@@ -148,7 +147,7 @@ public sealed class ConversionRecomputerTests
[Fact]
public async Task Recompute_NoRatesCache_ReturnsZeroAndKeepsCardsUntouched()
{
(ConversionRecomputer service, FakeKanjStore store, _) = Create();
(ConversionRecomputer service, TestKanjStore store, _) = Create();
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
int updated = await service.RecomputeAsync(CancellationToken.None);
@@ -160,7 +159,7 @@ public sealed class ConversionRecomputerTests
[Fact]
public async Task Recompute_CorruptedRatesCache_ReturnsZero()
{
(ConversionRecomputer service, FakeKanjStore store, TestSettingsStore settings) = Create();
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, "{это не JSON");
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -175,7 +174,7 @@ public sealed class ConversionRecomputerTests
[Fact]
public async Task OnRatesChangedAsync_PerformsFullRecompute()
{
(ConversionRecomputer service, FakeKanjStore store, TestSettingsStore settings) = Create();
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -190,7 +189,7 @@ public sealed class ConversionRecomputerTests
[Fact]
public async Task Recompute_SecondRun_RecomputesSameValues()
{
(ConversionRecomputer service, FakeKanjStore store, TestSettingsStore settings) = Create();
(ConversionRecomputer service, TestKanjStore store, TestSettingsStore settings) = Create();
settings.Preload(SettingsKeys.RatesCache, CacheJson(MockRates.Values));
store.SeedCard(Card("l_board", "b_1", from: 100, to: 100, cur: "USD"));
@@ -204,11 +203,11 @@ public sealed class ConversionRecomputerTests
// ─── Хелперы ────────────────────────────────────────────────────────────
private static (ConversionRecomputer Service, FakeKanjStore Store, TestSettingsStore Settings) Create()
private static (ConversionRecomputer Service, TestKanjStore Store, TestSettingsStore Settings) Create()
{
var store = new FakeKanjStore();
var store = new TestKanjStore();
var settings = new TestSettingsStore();
return (new ConversionRecomputer(settings.Store, store), store, settings);
return (new ConversionRecomputer(settings.Store, store.Store), store, settings);
}
// Карточка с бюджетом в колонке (BudgetCur задан → Budget не null, как маппинг адаптера).
@@ -238,7 +237,7 @@ public sealed class ConversionRecomputerTests
// Конверсия (Converted) карточки в фейке после пересчёта.
// store: Фейк-хранилище.
// cardId: Id карточки.
private static CardBudgetDto? ConvertedOf(FakeKanjStore store, string cardId) =>
private static CardBudgetDto? ConvertedOf(TestKanjStore store, string cardId) =>
store.CardDtos.Single(card => card.Id == cardId).Converted;
// Проверяет conv-поля карточки (From/To с допуском 0.001 и валюту).
@@ -248,7 +247,7 @@ public sealed class ConversionRecomputerTests
// convTo: Ожидаемая верхняя граница.
// convCur: Ожидаемая валюта конверсии.
private static void AssertConverted(
FakeKanjStore store,
TestKanjStore store,
string cardId,
double convFrom,
double convTo,
@@ -15,7 +15,6 @@ using Deal.Modules.Settings.Application.Models;
using Deal.Modules.Tenants.Application.Services;
using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Grpc;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants;
using Microsoft.Extensions.Logging.Abstractions;
@@ -52,7 +51,7 @@ public sealed class GrpcAiClassifierTests
Reason = "реклама",
Usage = new Usage { Prompt = 500, Completion = 40, Total = 540 },
};
(TestSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
settings.Preload(SettingsKeys.AiFilterPrompt, Json("Фильтруй. {domain} | {keywords}."));
settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain));
settings.Preload(SettingsKeys.DomainKeywords, Json(TestKeywords));
@@ -88,7 +87,7 @@ public sealed class GrpcAiClassifierTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.FilterUnavailable = true;
(TestSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
await Assert.ThrowsAsync<AiUnavailableException>(
@@ -102,7 +101,7 @@ public sealed class GrpcAiClassifierTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
string text = new string('а', 5000);
(TestSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
await classifier.FilterAsync(text, CancellationToken.None);
@@ -131,7 +130,7 @@ public sealed class GrpcAiClassifierTests
""",
Usage = new Usage { Prompt = 3000, Completion = 700, Total = 3700 },
};
(TestSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
settings.Preload(SettingsKeys.AiPrompt, Json("Разбор: {domain}."));
settings.Preload(SettingsKeys.CardPrompt, Json("Верни блок «О заявке»."));
settings.Preload(SettingsKeys.DomainDescription, Json(TestDomain));
@@ -191,7 +190,7 @@ public sealed class GrpcAiClassifierTests
Ok = false,
Usage = new Usage { Prompt = 900, Completion = 0, Total = 900 },
};
(TestSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
TestTenantLimitStore limits = new();
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore, limits);
@@ -212,7 +211,7 @@ public sealed class GrpcAiClassifierTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.ClassifyUnavailable = true;
(TestSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
await Assert.ThrowsAsync<AiUnavailableException>(
@@ -226,7 +225,7 @@ public sealed class GrpcAiClassifierTests
await AiGrpcTestHost.RunAsync(AiGrpcTestHost.DefaultToken, new RecordingAiService(), async (port, service) =>
{
service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Заголовок","stack":[],"is_spam":false}""" };
(TestSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
settings.Preload(SettingsKeys.AiProvider, Json("anthropic"));
settings.Preload(
SettingsKeys.AiConfigs,
@@ -263,7 +262,7 @@ public sealed class GrpcAiClassifierTests
{
service.ClassifyReply = new ClassifyReply { Ok = true, Json = """{"title":"Т","stack":[],"is_spam":false}""" };
string text = new string('б', 6000);
(TestSettingsStore settings, ISecretCipher cipher, FakeKanjStore kanjStore) = Context(port);
(TestSettingsStore settings, ISecretCipher cipher, TestKanjStore kanjStore) = Context(port);
IAiClassifier classifier = CreateClassifier(port, settings, cipher, kanjStore);
await classifier.ClassifyAsync(text, CancellationToken.None);
@@ -286,7 +285,7 @@ public sealed class GrpcAiClassifierTests
int port,
TestSettingsStore settings,
ISecretCipher cipher,
FakeKanjStore kanjStore,
TestKanjStore kanjStore,
TestTenantLimitStore? limits = null)
{
limits ??= new TestTenantLimitStore();
@@ -297,7 +296,7 @@ public sealed class GrpcAiClassifierTests
tenantContext,
connection,
new AiProviderConfigBuilder(settings.Store, cipher),
new AiClassifyContextBuilder(settings.Store, kanjStore),
new AiClassifyContextBuilder(settings.Store, kanjStore.Store),
new TokenUsageRecorder(settings.Store, limits.Store, tenantContext, new TokenUsageEventService(new TestTokenUsageEventStore().Store)),
NullLogger<GrpcAiClassifier>.Instance);
}
@@ -305,8 +304,8 @@ public sealed class GrpcAiClassifierTests
// Создаёт контекст сценария (пустые фейки; сценарий переопределяет настройки/доски).
// port: Порт хоста (не используется контекстом — единый вид хелперов).
// Возвращает: Кортеж фейков (настройки, шифр, канбан).
private static (TestSettingsStore Settings, ISecretCipher Cipher, FakeKanjStore Kanj) Context(int port)
=> (new TestSettingsStore(), TestCiphers.New(), new FakeKanjStore());
private static (TestSettingsStore Settings, ISecretCipher Cipher, TestKanjStore Kanj) Context(int port)
=> (new TestSettingsStore(), TestCiphers.New(), new TestKanjStore());
// Сериализует значение настройки в JSON-строку (как пишет SettingsStore).
// value: Значение (строка/список).
@@ -6,7 +6,6 @@ using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support;
@@ -18,18 +17,18 @@ public sealed class LocalColumnSuggesterTests
{
// Создаёт контекст теста: in-memory хранилища + адаптер поверх сервиса досок модуля.
// Возвращает: Кортеж (хранилище канбана, KV-настройки, адаптер).
private static (FakeKanjStore Store, TestSettingsStore Settings, LocalColumnSuggester Suggester) CreateContext()
private static (TestKanjStore Store, TestSettingsStore Settings, LocalColumnSuggester Suggester) CreateContext()
{
var store = new FakeKanjStore();
var store = new TestKanjStore();
var settings = new TestSettingsStore();
var suggester = new LocalColumnSuggester(store, settings.Store, new ContainersService(store, settings.Store));
var suggester = new LocalColumnSuggester(store.Store, settings.Store, new ContainersService(store.Store, settings.Store));
return (store, settings, suggester);
}
private static void SeedInbox(
string id,
string text,
FakeKanjStore store,
TestKanjStore store,
long receivedAtMs = 0) =>
store.SeedCard(new CardDto
{
@@ -58,7 +57,7 @@ public sealed class LocalColumnSuggesterTests
[Fact]
public async Task SuggestColumns_TooFewCards_ReturnsTooFewCardsReason()
{
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
for (int i = 1; i < SuggestHeuristics.MinInbox; i++)
{
SeedInbox($"l_{i}", "нужен python", store);
@@ -73,7 +72,7 @@ public sealed class LocalColumnSuggesterTests
[Fact]
public async Task SuggestColumns_CooldownActive_ReturnsCooldownReason()
{
(FakeKanjStore store, TestSettingsStore settings, LocalColumnSuggester suggester) = CreateContext();
(TestKanjStore store, TestSettingsStore settings, LocalColumnSuggester suggester) = CreateContext();
settings.Preload(SettingsKeys.LastSuggestAt,
DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture));
SeedInbox("l_1", "нужен python", store);
@@ -88,7 +87,7 @@ public sealed class LocalColumnSuggesterTests
[Fact]
public async Task SuggestColumns_NoThemes_ReturnsNothingGroupedReason()
{
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
string[] uniqueWords = ["python", "vue", "crm", "sql", "php", "mvp"];
for (int i = 0; i < uniqueWords.Length; i++)
{
@@ -104,7 +103,7 @@ public sealed class LocalColumnSuggesterTests
[Fact]
public async Task SuggestColumns_SimilarExistingBoard_ReturnsNothingGroupedReason()
{
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
store.SeedBoard(new ContainerDto
{
Id = "b_python",
@@ -129,7 +128,7 @@ public sealed class LocalColumnSuggesterTests
[Fact]
public async Task SuggestColumns_Success_CreatesSuggestedBoardsAndPlacesCards()
{
(FakeKanjStore store, TestSettingsStore settings, LocalColumnSuggester suggester) = CreateContext();
(TestKanjStore store, TestSettingsStore settings, LocalColumnSuggester suggester) = CreateContext();
SeedInbox("l_p1", "нужен python", store);
SeedInbox("l_p2", "нужен python", store);
SeedInbox("l_p3", "нужен python", store);
@@ -170,7 +169,7 @@ public sealed class LocalColumnSuggesterTests
[Fact]
public async Task SuggestColumns_SuccessThenImmediateRepeat_BlockedByCooldown()
{
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
SeedInbox("l_p1", "нужен python", store);
SeedInbox("l_p2", "нужен python", store);
SeedInbox("l_p3", "нужен python", store);
@@ -192,7 +191,7 @@ public sealed class LocalColumnSuggesterTests
[Fact]
public async Task SuggestKeywords_Success_ReturnsRepeatedWordMarkers()
{
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
SeedInbox("l_p1", "нужен python", store);
SeedInbox("l_p2", "нужен python", store);
SeedInbox("l_p3", "нужен python", store);
@@ -210,7 +209,7 @@ public sealed class LocalColumnSuggesterTests
[Fact]
public async Task SuggestKeywords_TooFewCards_ReturnsReason()
{
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
SeedInbox("l_1", "нужен python", store);
SeedInbox("l_2", "нужен python", store);
@@ -224,7 +223,7 @@ public sealed class LocalColumnSuggesterTests
[Fact]
public async Task SuggestKeywords_TrashAndArchiveCards_AreNotCounted()
{
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
SeedInbox("l_1", "нужен python", store);
SeedInbox("l_2", "нужен vue", store);
store.SeedCard(new CardDto { Id = "l_t1", Col = KanbanColumns.Trash, Content = new SourceContent { Text = "ищу такси" } });
@@ -240,7 +239,7 @@ public sealed class LocalColumnSuggesterTests
[Fact]
public async Task SuggestKeywords_NoRepeatedMarkers_ReturnsReason()
{
(FakeKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
(TestKanjStore store, _, LocalColumnSuggester suggester) = CreateContext();
string[] uniqueWords = ["python", "vue", "crm", "sql", "php", "mvp"];
for (int i = 0; i < uniqueWords.Length; i++)
{
@@ -18,7 +18,6 @@ using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants;
@@ -42,10 +41,10 @@ public sealed class PipelineWorkerSchedulerTests
private sealed record Context(
PipelineWorkerScheduler Scheduler,
TestPipelineStore PipelineA,
FakeKanjStore KanjA,
TestKanjStore KanjA,
SseSubscription SubscriptionA,
TestPipelineStore PipelineB,
FakeKanjStore KanjB,
TestKanjStore KanjB,
SseSubscription SubscriptionB,
PipelinePumpGate PumpGate,
TenantContext TenantContext,
@@ -153,8 +152,8 @@ public sealed class PipelineWorkerSchedulerTests
var tenantContext = new TenantContext();
var pipelineA = new TestPipelineStore(throwOnList: withThrowingQueueReadA);
var pipelineB = new TestPipelineStore();
var kanjA = new FakeKanjStore();
var kanjB = new FakeKanjStore();
var kanjA = new TestKanjStore();
var kanjB = new TestKanjStore();
var settingsA = new TestSettingsStore();
var settingsB = new TestSettingsStore();
var pumpGate = new PipelinePumpGate();
@@ -172,7 +171,7 @@ public sealed class PipelineWorkerSchedulerTests
services.AddSingleton<IAiClassifier>(aiClassifier.Classifier);
// Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant
// (эталон StorageTickSchedulerTests/ConnectionStringProvider.ForTenant).
services.AddScoped<ICardStore>(provider => TenantOf(provider) == TenantA ? kanjA : kanjB);
services.AddScoped<ICardStore>(provider => TenantOf(provider) == TenantA ? kanjA.Store : kanjB.Store);
services.AddScoped<ISettingsStore>(provider => TenantOf(provider) == TenantA ? settingsA.Store : settingsB.Store);
services.AddScoped<IPipelineStore>(provider => TenantOf(provider) == TenantA ? pipelineA.Store : pipelineB.Store);
// Реальные сервисы модуля Pipeline — как AddPipelineModule в Program.cs: цикл резолвит их в tenant-scope.
@@ -7,7 +7,6 @@ using Deal.Modules.Pipeline.Application.Parse;
using Deal.Modules.Pipeline.Application.Services;
using Deal.Modules.Settings.Application.Models;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings;
@@ -26,7 +25,7 @@ public sealed class PipelineWorkerServiceTests
private sealed record Context(
PipelineWorkerService Worker,
TestPipelineStore PipelineStore,
FakeKanjStore KanjStore,
TestKanjStore KanjStore,
TestSettingsStore Settings,
TestMlClient MlClient,
TestAiClassifier AiClassifier);
@@ -38,16 +37,16 @@ public sealed class PipelineWorkerServiceTests
{
var settings = new TestSettingsStore();
var store = pipelineStore ?? new TestPipelineStore();
var kanjStore = new FakeKanjStore();
var kanjStore = new TestKanjStore();
var mlClient = new TestMlClient { Predict = NotReadyPrediction() };
var aiClassifier = new TestAiClassifier();
var rules = new IncomingRules(settings.Store);
var fieldsParser = new LocalFieldsParser(settings.Store);
var processing = new PipelineProcessingService(store.Store, mlClient.Client, new PipelineIngestService(store.Store));
var composer = new CardComposer(kanjStore, settings.Store);
var writer = new PipelineCardWriter(kanjStore, store.Store, composer);
var composer = new CardComposer(kanjStore.Store, settings.Store);
var writer = new PipelineCardWriter(kanjStore.Store, store.Store, composer);
var worker = new PipelineWorkerService(
store.Store, settings.Store, rules, kanjStore, mlClient.Client, aiClassifier.Classifier, processing, writer, fieldsParser);
store.Store, settings.Store, rules, kanjStore.Store, mlClient.Client, aiClassifier.Classifier, processing, writer, fieldsParser);
return new Context(worker, store, kanjStore, settings, mlClient, aiClassifier);
}
@@ -17,7 +17,6 @@ using Deal.Modules.Tenants.Application.Models;
using Deal.SharedKernel.Tenants.Abstractions;
using Deal.SharedKernel.Tenants.Models;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Cards;
using Deal.Tests.Unit.Modules.Kanban;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Modules.Tenants;
@@ -43,8 +42,8 @@ public sealed class StorageTickSchedulerTests
[Fact]
public async Task RunCycle_TicksEveryTenantInOwnScopeAndPublishesToastToEachTenantChannel()
{
FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old");
FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
TestKanjStore storeA = StoreWithExpiredInboxCard("l_a_old");
TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var settings = new TestSettingsStore();
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
var tenantContext = new TenantContext();
@@ -71,8 +70,8 @@ public sealed class StorageTickSchedulerTests
[Fact]
public async Task RunCycle_ZeroCountersTenant_PublishesToastOnlyToTenantWithChanges()
{
FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old");
var storeB = new FakeKanjStore();
TestKanjStore storeA = StoreWithExpiredInboxCard("l_a_old");
var storeB = new TestKanjStore();
storeB.SeedCard(Card("l_b_fresh", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromHours(1))));
var settings = new TestSettingsStore();
var tenantContext = new TenantContext();
@@ -100,16 +99,16 @@ public sealed class StorageTickSchedulerTests
public async Task RunCycle_TenantTickFailure_DoesNotAbortOtherTenants()
{
// У тенанта A настройки падают (имитация сбоя схемы/БД) — тик A логирует ошибку, B обрабатывается.
FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var tenantContext = new TenantContext();
var settingsByTenant = new Dictionary<Guid, ISettingsStore>
{
[TenantA] = new ThrowingSettingsStore(),
[TenantB] = new TestSettingsStore().Store,
};
var storesByTenant = new Dictionary<Guid, FakeKanjStore>
var storesByTenant = new Dictionary<Guid, TestKanjStore>
{
[TenantA] = new FakeKanjStore(),
[TenantA] = new TestKanjStore(),
[TenantB] = storeB,
};
await using ServiceProvider provider = BuildProvider(
@@ -138,7 +137,7 @@ public sealed class StorageTickSchedulerTests
await using ServiceProvider provider = BuildProvider(
new ThrowingTenantRepository(),
tenantContext,
new Dictionary<Guid, FakeKanjStore>(),
new Dictionary<Guid, TestKanjStore>(),
new Dictionary<Guid, ISettingsStore>());
StorageTickScheduler scheduler = CreateScheduler(provider);
@@ -151,7 +150,7 @@ public sealed class StorageTickSchedulerTests
[Fact]
public async Task RunCycle_PurgesExpiredRejectedRowsAndPublishesRejectedPurgeToast()
{
var kanjStore = new FakeKanjStore();
var kanjStore = new TestKanjStore();
var settings = new TestSettingsStore();
var tenantContext = new TenantContext();
var pipelineStoreA = new TestPipelineStore();
@@ -161,7 +160,7 @@ public sealed class StorageTickSchedulerTests
await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
tenantContext,
new Dictionary<Guid, FakeKanjStore> { [TenantA] = kanjStore, [TenantB] = new FakeKanjStore() },
new Dictionary<Guid, TestKanjStore> { [TenantA] = kanjStore, [TenantB] = new TestKanjStore() },
new Dictionary<Guid, ISettingsStore> { [TenantA] = settings.Store, [TenantB] = settings.Store },
new Dictionary<Guid, TestPipelineStore> { [TenantA] = pipelineStoreA, [TenantB] = new TestPipelineStore() });
@@ -182,16 +181,16 @@ public sealed class StorageTickSchedulerTests
[Fact]
public async Task RunCycle_DueReminder_PublishesReminderDueToTenantChannelAndMarksFired()
{
var cardStoreA = new FakeKanjStore();
var cardStoreA = new TestKanjStore();
cardStoreA.SeedCard(HoldCard("c_a_past", title: "Отложенный бот", reminderAtMs: NowMs() - 60_000));
cardStoreA.SeedCard(HoldCard("c_a_future", title: "Будущий", reminderAtMs: NowMs() + 60_000));
cardStoreA.SeedCard(HoldCard("c_a_work", stage: "work", title: "В работе", reminderAtMs: NowMs() - 60_000));
var cardStoreB = new FakeKanjStore();
var cardStoreB = new TestKanjStore();
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
tenantContext,
new Dictionary<Guid, FakeKanjStore> { [TenantA] = cardStoreA, [TenantB] = cardStoreB },
new Dictionary<Guid, TestKanjStore> { [TenantA] = cardStoreA, [TenantB] = cardStoreB },
new Dictionary<Guid, ISettingsStore> { [TenantA] = new TestSettingsStore().Store, [TenantB] = new TestSettingsStore().Store },
new Dictionary<Guid, TestPipelineStore> { [TenantA] = new(), [TenantB] = new() });
@@ -211,14 +210,14 @@ public sealed class StorageTickSchedulerTests
Assert.False(subscriptionB.Events.TryRead(out _));
// «Выстрелившее» помечено fired: повторная выборка due пуста (признак держит строка БД).
Assert.Empty(await cardStoreA.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None));
Assert.Empty(await cardStoreA.Store.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None));
Assert.False(tenantContext.HasTenant);
}
[Fact]
public async Task RunCycle_RemindersDisabled_ClearsExpiredAndPublishesNoReminderDue()
{
var cardStoreA = new FakeKanjStore();
var cardStoreA = new TestKanjStore();
cardStoreA.SeedCard(HoldCard("c_past", title: "Старое", reminderAtMs: NowMs() - 60_000));
var settingsA = new TestSettingsStore();
settingsA.Preload(SettingsKeys.RemindersEnabled, "false");
@@ -226,7 +225,7 @@ public sealed class StorageTickSchedulerTests
await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA)).Repository,
tenantContext,
new Dictionary<Guid, FakeKanjStore> { [TenantA] = cardStoreA },
new Dictionary<Guid, TestKanjStore> { [TenantA] = cardStoreA },
new Dictionary<Guid, ISettingsStore> { [TenantA] = settingsA.Store },
new Dictionary<Guid, TestPipelineStore> { [TenantA] = new() });
@@ -246,13 +245,13 @@ public sealed class StorageTickSchedulerTests
{
// У тенанта A проверка напоминаний падает (имитация сбоя схемы/БД на ListDueAsync) — ветка логируется
// и тик A завершается, тенант B обрабатывается (автоархив + тост) — проход жив.
var cardStoreA = new ThrowingDueKanjStore();
FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var cardStoreA = new TestKanjStore(throwOnDueReminders: true);
TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
tenantContext,
new Dictionary<Guid, FakeKanjStore> { [TenantA] = cardStoreA, [TenantB] = storeB },
new Dictionary<Guid, TestKanjStore> { [TenantA] = cardStoreA, [TenantB] = storeB },
new Dictionary<Guid, ISettingsStore> { [TenantA] = new TestSettingsStore().Store, [TenantB] = new TestSettingsStore().Store },
new Dictionary<Guid, TestPipelineStore> { [TenantA] = new(), [TenantB] = new() });
@@ -282,14 +281,14 @@ public sealed class StorageTickSchedulerTests
private static ServiceProvider BuildProvider(
TestTenantRepository tenants,
TenantContext tenantContext,
FakeKanjStore storeA,
FakeKanjStore storeB,
TestKanjStore storeA,
TestKanjStore storeB,
TestSettingsStore settings)
{
return BuildProvider(
tenants.Repository,
tenantContext,
new Dictionary<Guid, FakeKanjStore> { [TenantA] = storeA, [TenantB] = storeB },
new Dictionary<Guid, TestKanjStore> { [TenantA] = storeA, [TenantB] = storeB },
new Dictionary<Guid, ISettingsStore> { [TenantA] = settings.Store, [TenantB] = settings.Store });
}
@@ -303,7 +302,7 @@ public sealed class StorageTickSchedulerTests
private static ServiceProvider BuildProvider(
ITenantRepository tenants,
TenantContext tenantContext,
Dictionary<Guid, FakeKanjStore> storesByTenant,
Dictionary<Guid, TestKanjStore> storesByTenant,
Dictionary<Guid, ISettingsStore> settingsByTenant,
Dictionary<Guid, TestPipelineStore>? pipelineStoresByTenant = null)
{
@@ -320,7 +319,7 @@ public sealed class StorageTickSchedulerTests
services.AddSingleton(tenants);
// Тенант-scoped адаптеры: реальные строят TenantDbContext по схеме текущего тенанта — фейк
// выбирает хранилище по тому же ITenantContext, который планировщик заполняет SetTenant.
services.AddScoped<ICardStore>(provider => storesByTenant[TenantOf(provider)]);
services.AddScoped<ICardStore>(provider => storesByTenant[TenantOf(provider)].Store);
services.AddScoped<ISettingsStore>(provider => settingsByTenant[TenantOf(provider)]);
services.AddScoped<IPipelineStore>(provider => pipelineStoresByTenant[TenantOf(provider)].Store);
services.AddSingleton<IMlClient>(new TestMlClient().Client);
@@ -360,17 +359,7 @@ public sealed class StorageTickSchedulerTests
private static TenantRecordDto Tenant(Guid id) =>
new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow);
// Хранилище карточек со сбоем выборки due-напоминаний: ListDueRemindersAsync бросает (сценарий
// «БД/схема недоступны» на проверке напоминаний — ветка логируется, тик тенанта/проход живы, как в
// AdminTickOrchestratorTests).
private sealed class ThrowingDueKanjStore : FakeKanjStore
{
/// <inheritdoc />
public override Task<IReadOnlyList<CardReminderDueDto>> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct)
{
throw new InvalidOperationException("Тестовый сбой выборки due-напоминаний (ListDueRemindersAsync).");
}
}
// Сбой выборки due-напоминаний задаётся флагом throwOnDueReminders в TestKanjStore.
// Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем.
private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
@@ -395,9 +384,9 @@ public sealed class StorageTickSchedulerTests
// Хранилище с просроченной карточкой «Неразобранного» — кандидатом автоархива дефолтного тика.
// cardId: Id карточки.
// Возвращает: Фейк-хранилище с одной старой карточкой inbox.
private static FakeKanjStore StoreWithExpiredInboxCard(string cardId)
private static TestKanjStore StoreWithExpiredInboxCard(string cardId)
{
var store = new FakeKanjStore();
var store = new TestKanjStore();
store.SeedCard(Card(cardId, KanbanColumns.Inbox, ReceivedAtMsAgo(ExpiredAge)));
return store;
}
@@ -2,120 +2,262 @@ using Deal.Modules.Cards.Application.Models;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Abstractions;
using Deal.Modules.Kanban.Application.Models;
using NSubstitute;
namespace Deal.Tests.Unit.Modules.Cards;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// In-memory реализация <see cref="ICardStore"/> для unit-тестов сервисов единого домена карточки.
/// Подставка <see cref="ICardStore"/> на списках: сервисы получают NSubstitute-подставку
/// (<see cref="Store"/>), тесты сеют/проверяют состояние через <see cref="SeedBoard"/>,
/// <see cref="SeedCard"/>, <see cref="AddCard"/>, <see cref="Boards"/>, <see cref="CardDtos"/>,
/// <see cref="Moves"/>, <see cref="SearchCalls"/>. Сбой записи задаётся <see cref="FailAddCard"/>.
/// </summary>
public class FakeKanjStore : ICardStore
public sealed class TestKanjStore
{
private readonly List<ContainerDto> _boards = [];
private readonly List<CardDto> _cards = [];
private readonly List<CardMoveDto> _moves = [];
private readonly List<(string Query, int Limit)> _searchCalls = [];
private readonly HashSet<string> _firedById = new(StringComparer.Ordinal);
private readonly Dictionary<string, DateTimeOffset> _archivedAtById = new(StringComparer.Ordinal);
private readonly bool _throwOnDueReminders;
/// <summary>
/// Доски фейка (копия на момент обращения) — проверки BoardsServiceTests и LocalColumnSuggesterTests.
/// Подставка порта единого домена карточки (создаётся в конструкторе).
/// </summary>
public ICardStore Store { get; }
/// <summary>
/// Доски хранилища (копия на момент обращения).
/// </summary>
public IReadOnlyList<ContainerDto> Boards => _boards.ToList();
/// <summary>
/// Карточки фейка как тройки
/// Карточки хранилища как тройки (id, колонка, флаг «новая»).
/// </summary>
public IReadOnlyList<(string CardId, string Col, bool IsNew)> Cards =>
_cards.Select(card => (card.Id, card.Col, card.IsNew)).ToList();
/// <summary>
/// Полные карточки фейка
/// Полные карточки хранилища (копия на момент обращения).
/// </summary>
public IReadOnlyList<CardDto> CardDtos => _cards.ToList();
/// <summary>
/// Флаг «сбой записи карточки»
/// Флаг «сбой записи карточки».
/// </summary>
public bool FailAddCard { get; set; }
/// <summary>
/// Записи журнала CardMoves
/// Записи журнала CardMoves.
/// </summary>
public IReadOnlyList<CardMoveDto> Moves => _moves.ToList();
/// <summary>
/// Вызовы SearchCardsAsync как пары
/// Вызовы SearchCardsAsync как пары (запрос, лимит).
/// </summary>
public IReadOnlyList<(string Query, int Limit)> SearchCalls => _searchCalls.ToList();
/// <summary>
/// Создаёт подставку с пустыми списками.
/// </summary>
/// <param name="throwOnDueReminders">Сценарий сбоя выборки due-напоминаний: ListDueRemindersAsync бросает.</param>
public TestKanjStore(bool throwOnDueReminders = false)
{
_throwOnDueReminders = throwOnDueReminders;
Store = Substitute.For<ICardStore>();
ConfigureContainers();
ConfigureCards();
ConfigureComments();
ConfigureMoves();
ConfigureStorage();
ConfigureSelected();
ConfigureReminders();
}
/// <summary>
/// Кладёт доску напрямую
/// </summary>
/// <param name="board">Доска как если бы была сохранена в БД.</param>
public void SeedBoard(ContainerDto board)
{
_boards.Add(board);
}
public void SeedBoard(ContainerDto board) => _boards.Add(board);
/// <summary>
/// Кладёт карточку в колонку.
/// </summary>
/// <param name="cardId">Id карточки.</param>
/// <param name="col">Колонка (inbox/доска).</param>
public void AddCard(string cardId, string col)
{
SeedCard(new CardDto { Id = cardId, Col = col });
}
public void AddCard(string cardId, string col) => SeedCard(new CardDto { Id = cardId, Col = col });
/// <summary>
/// Кладёт полную карточку.
/// </summary>
/// <param name="card">Карточка как если бы была сохранена в БД (комментарии — приложенным массивом).</param>
public void SeedCard(CardDto card)
public void SeedCard(CardDto card) => _cards.Add(card);
/// <summary>
/// Задаёт метку архивации карточки
/// </summary>
/// <param name="cardId">Id карточки.</param>
/// <param name="archivedAt">Метка архивации (когда карточка ушла в архив).</param>
public void SetArchivedAt(string cardId, DateTimeOffset archivedAt) => _archivedAtById[cardId] = archivedAt;
/// <summary>
/// Метка архивации карточки — проверка archived_at после автоархива тика.
/// </summary>
/// <param name="cardId">Id карточки.</param>
/// <returns>Метка архивации либо null — карточки нет/не архивирована/метка сброшена.</returns>
public DateTimeOffset? ArchivedAtOf(string cardId) =>
_archivedAtById.TryGetValue(cardId, out DateTimeOffset archivedAt) ? archivedAt : null;
private void ConfigureContainers()
{
_cards.Add(card);
Store.ListContainersAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(ci => ListContainers(ci.ArgAt<string?>(0)));
Store.GetContainerAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => _boards.FirstOrDefault(board => board.Id == ci.ArgAt<string>(0)));
Store.When(s => s.CreateContainerAsync(Arg.Any<ContainerDto>(), Arg.Any<CancellationToken>()))
.Do(ci => _boards.Add(ci.Arg<ContainerDto>()));
Store.When(s => s.UpdateContainerAsync(Arg.Any<ContainerDto>(), Arg.Any<CancellationToken>()))
.Do(ci => UpdateContainer(ci.Arg<ContainerDto>()));
Store.DeleteContainerAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => DeleteContainer(ci.ArgAt<string>(0)));
Store.When(s => s.ReorderContainersAsync(
Arg.Any<string>(), Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>()))
.Do(ci => ReorderContainers(ci.ArgAt<string>(0), ci.ArgAt<IReadOnlyList<string>>(1)));
}
// ── Контейнеры (колонки/стадии/зоны) ─────────────────────────────────
/// <inheritdoc />
public Task<IReadOnlyList<ContainerDto>> ListContainersAsync(string? space, CancellationToken ct)
private void ConfigureCards()
{
IReadOnlyList<ContainerDto> ordered = _boards
Store.ListCardsAsync(Arg.Any<CardsQuery>(), Arg.Any<CancellationToken>())
.Returns(ci => ListCards(ci.Arg<CardsQuery>()));
Store.SearchCardsAsync(Arg.Any<string>(), Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => SearchCards(ci.ArgAt<string>(0), ci.ArgAt<int>(1)));
Store.GetCardAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => _cards.FirstOrDefault(card => card.Id == ci.ArgAt<string>(0)));
Store.GetCardBySourceAsync(Arg.Any<SourceRef>(), Arg.Any<CancellationToken>())
.Returns(ci => GetCardBySource(ci.Arg<SourceRef>()));
Store.When(s => s.AddCardAsync(Arg.Any<CardSnapshot>(), Arg.Any<CancellationToken>()))
.Do(ci => AddCard(ci.Arg<CardSnapshot>()));
Store.When(s => s.UpdateColumnAsync(Arg.Any<CardColumnUpdateDto>(), Arg.Any<CancellationToken>()))
.Do(ci => UpdateColumn(ci.Arg<CardColumnUpdateDto>()));
Store.ApplyReclassificationAsync(Arg.Any<CardReclassificationDto>(), Arg.Any<CancellationToken>())
.Returns(ci => ApplyReclassification(ci.Arg<CardReclassificationDto>()));
Store.When(s => s.UpdateSeenAsync(Arg.Any<string?>(), Arg.Any<string?>(), Arg.Any<CancellationToken>()))
.Do(ci => UpdateSeen(ci.ArgAt<string?>(0), ci.ArgAt<string?>(1)));
Store.When(s => s.DeleteForeverAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci => DeleteForever(ci.ArgAt<string>(0)));
Store.ClearColAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => ClearCol(ci.ArgAt<string>(0)));
Store.CountCardsByColAsync(Arg.Any<CancellationToken>())
.Returns(_ => CountCardsByCol());
}
private void ConfigureComments()
{
Store.ListCommentsAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => _cards.FirstOrDefault(card => card.Id == ci.ArgAt<string>(0))?.Comments
?? Array.Empty<CardCommentDto>());
Store.When(s => s.AddCommentAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci => AddComment(
ci.ArgAt<string>(0), ci.ArgAt<string>(1), ci.ArgAt<string>(2), ci.ArgAt<string>(3)));
}
private void ConfigureMoves()
{
Store.When(s => s.AddMoveAsync(Arg.Any<CardMoveDto>(), Arg.Any<CancellationToken>()))
.Do(ci => _moves.Add(ci.Arg<CardMoveDto>()));
Store.CountMovesAsync(Arg.Any<CancellationToken>()).Returns(_ => _moves.Count);
Store.GetAiMarkupExamplesAsync(Arg.Any<int>(), Arg.Any<CancellationToken>())
.Returns(ci => GetAiMarkupExamples(ci.ArgAt<int>(0)));
}
private void ConfigureStorage()
{
Store.ListArchiveCandidatesAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(ci => ListArchiveCandidates(ci.ArgAt<DateTimeOffset>(0)));
Store.ArchiveAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(ci => Archive(ci.ArgAt<IReadOnlyList<string>>(0), ci.ArgAt<DateTimeOffset>(1)));
Store.ListExpiredArchiveCandidatesAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(ci => ListExpiredArchiveCandidates(ci.ArgAt<DateTimeOffset>(0)));
Store.ListTrashCandidatesAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(ci => ListTrashCandidates(ci.ArgAt<DateTimeOffset>(0)));
Store.PurgeAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>())
.Returns(ci => Purge(ci.ArgAt<IReadOnlyList<string>>(0)));
Store.ListCardsForConversionAsync(Arg.Any<CancellationToken>())
.Returns(_ => ListCardsForConversion());
Store.When(s => s.UpdateConversionAsync(
Arg.Any<string>(), Arg.Any<double?>(), Arg.Any<double?>(), Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci => UpdateConversion(
ci.ArgAt<string>(0), ci.ArgAt<double?>(1), ci.ArgAt<double?>(2), ci.ArgAt<string>(3)));
}
private void ConfigureSelected()
{
Store.ListSelectedCardsAsync(Arg.Any<string?>(), Arg.Any<CancellationToken>())
.Returns(ci => ListSelectedCards(ci.ArgAt<string?>(0)));
Store.PatchCardAsync(Arg.Any<string>(), Arg.Any<CardPatch>(), Arg.Any<CancellationToken>())
.Returns(ci => PatchCard(ci.ArgAt<string>(0), ci.Arg<CardPatch>()));
Store.AddLinkAsync(Arg.Any<string>(), Arg.Any<CardLinkDto>(), Arg.Any<CancellationToken>())
.Returns(ci => AddLink(ci.ArgAt<string>(0), ci.Arg<CardLinkDto>()));
Store.RemoveLinkAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => RemoveLink(ci.ArgAt<string>(0), ci.ArgAt<string>(1)));
Store.AddFileAsync(Arg.Any<string>(), Arg.Any<CardFileDto>(), Arg.Any<CancellationToken>())
.Returns(ci => AddFile(ci.ArgAt<string>(0), ci.Arg<CardFileDto>()));
Store.RemoveFileAsync(Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => RemoveFile(ci.ArgAt<string>(0), ci.ArgAt<string>(1)));
Store.MoveCardStageAsync(
Arg.Any<string>(), Arg.Any<string>(), Arg.Any<CardHistoryDto>(), Arg.Any<long>(), Arg.Any<CancellationToken>())
.Returns(ci => MoveCardStage(
ci.ArgAt<string>(0), ci.ArgAt<string>(1), ci.Arg<CardHistoryDto>(), ci.ArgAt<long>(3)));
}
private void ConfigureReminders()
{
Store.When(s => s.SetReminderAsync(Arg.Any<string>(), Arg.Any<long>(), Arg.Any<CancellationToken>()))
.Do(ci => SetReminder(ci.ArgAt<string>(0), ci.ArgAt<long>(1)));
Store.When(s => s.ClearReminderAsync(Arg.Any<string>(), Arg.Any<CancellationToken>()))
.Do(ci => ClearReminder(ci.ArgAt<string>(0)));
Store.ClearStageAsync(Arg.Any<string>(), Arg.Any<CancellationToken>())
.Returns(ci => ClearStage(ci.ArgAt<string>(0)));
if (_throwOnDueReminders)
{
Store.ListDueRemindersAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns<Task<IReadOnlyList<CardReminderDueDto>>>(_ =>
throw new InvalidOperationException("Тестовый сбой выборки due-напоминаний (ListDueRemindersAsync)."));
}
else
{
Store.ListDueRemindersAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(ci => ListDueReminders(ci.ArgAt<DateTimeOffset>(0)));
}
Store.When(s => s.MarkRemindersFiredAsync(Arg.Any<IReadOnlyList<string>>(), Arg.Any<CancellationToken>()))
.Do(ci => MarkRemindersFired(ci.ArgAt<IReadOnlyList<string>>(0)));
Store.ClearExpiredRemindersAsync(Arg.Any<DateTimeOffset>(), Arg.Any<CancellationToken>())
.Returns(ci => ClearExpiredReminders(ci.ArgAt<DateTimeOffset>(0)));
Store.ListInboxWithSourceAsync(Arg.Any<CancellationToken>())
.Returns(_ => ListInboxWithSource());
}
private IReadOnlyList<ContainerDto> ListContainers(string? space)
{
return _boards
.Where(board => space is null || board.Space == space)
.OrderBy(board => board.Suggested)
.ThenBy(board => board.Order)
.ToList();
return Task.FromResult(ordered);
}
/// <inheritdoc />
public Task<ContainerDto?> GetContainerAsync(string containerId, CancellationToken ct)
{
return Task.FromResult(_boards.FirstOrDefault(board => board.Id == containerId));
}
/// <inheritdoc />
public Task CreateContainerAsync(ContainerDto container, CancellationToken ct)
{
_boards.Add(container);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task UpdateContainerAsync(ContainerDto container, CancellationToken ct)
private void UpdateContainer(ContainerDto container)
{
int index = _boards.FindIndex(item => item.Id == container.Id);
_boards[index] = container;
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> DeleteContainerAsync(string containerId, CancellationToken ct)
private int DeleteContainer(string containerId)
{
List<CardDto> moved = _cards.Where(card => card.Col == containerId).ToList();
int moved = _cards.Count(card => card.Col == containerId);
for (int i = 0; i < _cards.Count; i++)
{
if (_cards[i].Col == containerId)
@@ -125,54 +267,39 @@ public class FakeKanjStore : ICardStore
}
_boards.RemoveAll(board => board.Id == containerId);
return Task.FromResult(moved.Count);
return moved;
}
/// <inheritdoc />
public Task ReorderContainersAsync(
string space,
IReadOnlyList<string> containerIds,
CancellationToken ct)
private void ReorderContainers(string space, IReadOnlyList<string> containerIds)
{
for (int i = 0; i < containerIds.Count; i++)
{
string containerId = containerIds[i];
int index = _boards.FindIndex(board => board.Id == containerId && board.Space == space);
int index = _boards.FindIndex(board => board.Id == containerIds[i] && board.Space == space);
if (index >= 0)
{
_boards[index] = _boards[index] with { Order = i };
}
}
return Task.CompletedTask;
}
// ── Карточки ─────────────────────────────────────────────────────────
/// <inheritdoc />
public Task<IReadOnlyList<CardDto>> ListCardsAsync(CardsQuery query, CancellationToken ct)
private IReadOnlyList<CardDto> ListCards(CardsQuery query)
{
IEnumerable<CardDto> result = query.Col is null
? _cards
: _cards.Where(card => card.Col == query.Col);
return Task.FromResult<IReadOnlyList<CardDto>>(
result.OrderByDescending(card => card.ReceivedAtMs).ToList());
return result.OrderByDescending(card => card.ReceivedAtMs).ToList();
}
/// <inheritdoc />
public Task<IReadOnlyList<CardDto>> SearchCardsAsync(
string q,
int limit,
CancellationToken ct)
private IReadOnlyList<CardDto> SearchCards(string q, int limit)
{
_searchCalls.Add((q, limit));
string lowered = q.Trim().ToLowerInvariant();
if (lowered.Length == 0)
{
return Task.FromResult<IReadOnlyList<CardDto>>(Array.Empty<CardDto>());
return Array.Empty<CardDto>();
}
IReadOnlyList<CardDto> result = _cards
return _cards
.Where(card => card.Title.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal)
|| card.Summary.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal)
|| card.Contact.ToLowerInvariant().Contains(lowered, StringComparison.Ordinal)
@@ -180,38 +307,26 @@ public class FakeKanjStore : ICardStore
.OrderByDescending(card => card.ReceivedAtMs)
.Take(limit)
.ToList();
return Task.FromResult<IReadOnlyList<CardDto>>(result);
}
/// <inheritdoc />
public Task<CardDto?> GetCardAsync(string cardId, CancellationToken ct)
{
return Task.FromResult(_cards.FirstOrDefault(card => card.Id == cardId));
}
/// <inheritdoc />
public Task<CardDto?> GetCardBySourceAsync(
SourceRef source,
CancellationToken ct)
private CardDto? GetCardBySource(SourceRef source)
{
if (string.IsNullOrEmpty(source.Kind))
{
return Task.FromResult<CardDto?>(null);
return null;
}
string externalId = source.ExternalId ?? string.Empty;
string originRef = source.OriginRef ?? string.Empty;
CardDto? match = _cards
return _cards
.Where(card => card.Source.Kind == source.Kind
&& (card.Source.ExternalId ?? string.Empty) == externalId
&& (card.Source.OriginRef ?? string.Empty) == originRef)
.OrderByDescending(card => card.ReceivedAtMs)
.FirstOrDefault();
return Task.FromResult(match);
}
/// <inheritdoc />
public Task AddCardAsync(CardSnapshot snapshot, CancellationToken ct)
private void AddCard(CardSnapshot snapshot)
{
if (FailAddCard)
{
@@ -249,16 +364,14 @@ public class FakeKanjStore : ICardStore
CreatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
});
return Task.CompletedTask;
}
/// <inheritdoc />
public Task UpdateColumnAsync(CardColumnUpdateDto update, CancellationToken ct)
private void UpdateColumn(CardColumnUpdateDto update)
{
int index = _cards.FindIndex(card => card.Id == update.CardId);
if (index < 0)
{
return Task.CompletedTask;
return;
}
_cards[index] = _cards[index] with
@@ -276,17 +389,14 @@ public class FakeKanjStore : ICardStore
{
_archivedAtById.Remove(update.CardId);
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<bool> ApplyReclassificationAsync(CardReclassificationDto update, CancellationToken ct)
private bool ApplyReclassification(CardReclassificationDto update)
{
int index = _cards.FindIndex(card => card.Id == update.CardId);
if (index < 0)
{
return Task.FromResult(false);
return false;
}
_cards[index] = _cards[index] with
@@ -304,40 +414,28 @@ public class FakeKanjStore : ICardStore
Contacts = update.Contacts,
MatchHits = update.MatchHits,
};
return Task.FromResult(true);
return true;
}
/// <inheritdoc />
public Task UpdateSeenAsync(
string? cardId,
string? col,
CancellationToken ct)
private void UpdateSeen(string? cardId, string? col)
{
for (int i = 0; i < _cards.Count; i++)
{
bool matches = cardId is not null
? _cards[i].Id == cardId
: col is null || _cards[i].Col == col;
bool matches = cardId is not null ? _cards[i].Id == cardId : col is null || _cards[i].Col == col;
if (matches && _cards[i].IsNew)
{
_cards[i] = _cards[i] with { IsNew = false };
}
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task DeleteForeverAsync(string cardId, CancellationToken ct)
private void DeleteForever(string cardId)
{
// Удаляется карточка (с комментариями — они часть карточки в фейке); журнал CardMoves не трогаем.
_cards.RemoveAll(card => card.Id == cardId);
_archivedAtById.Remove(cardId);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> ClearColAsync(string col, CancellationToken ct)
private int ClearCol(string col)
{
List<string> removedIds = _cards.Where(card => card.Col == col).Select(card => card.Id).ToList();
_cards.RemoveAll(card => card.Col == col);
@@ -346,64 +444,33 @@ public class FakeKanjStore : ICardStore
_archivedAtById.Remove(cardId);
}
return Task.FromResult(removedIds.Count);
return removedIds.Count;
}
/// <inheritdoc />
public Task<IReadOnlyDictionary<string, CardColumnCountDto>> CountCardsByColAsync(CancellationToken ct)
private IReadOnlyDictionary<string, CardColumnCountDto> CountCardsByCol()
{
IReadOnlyDictionary<string, CardColumnCountDto> result = _cards
return _cards
.GroupBy(card => card.Col)
.ToDictionary(
group => group.Key,
group => new CardColumnCountDto(group.Count(), group.Count(card => card.IsNew)));
return Task.FromResult(result);
}
// ── Комментарии (LeadComments) ────────────────────────────────────────
/// <inheritdoc />
public Task<IReadOnlyList<CardCommentDto>> ListCommentsAsync(string cardId, CancellationToken ct)
{
CardDto? card = _cards.FirstOrDefault(item => item.Id == cardId);
return Task.FromResult(card?.Comments ?? Array.Empty<CardCommentDto>());
}
/// <inheritdoc />
public Task AddCommentAsync(
string commentId,
string cardId,
string by,
string text,
CancellationToken ct)
private void AddComment(string commentId, string cardId, string by, string text)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0)
{
return Task.CompletedTask;
return;
}
// Метка времени свежего комментария — «только что» (адаптер считает HumanAge от CreatedAt = UtcNow).
CardCommentDto comment = new(commentId, by, text, "только что");
IReadOnlyList<CardCommentDto> updated = _cards[index].Comments.Concat(new[] { comment }).ToList();
_cards[index] = _cards[index] with { Comments = updated };
return Task.CompletedTask;
}
// ── Журнал CardMoves (learning_log) ───────────────────────────────────
/// <inheritdoc />
public Task AddMoveAsync(CardMoveDto move, CancellationToken ct)
{
_moves.Add(move);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> CountMovesAsync(CancellationToken ct) => Task.FromResult(_moves.Count);
/// <inheritdoc />
public Task<IReadOnlyList<AiMarkupExampleDto>> GetAiMarkupExamplesAsync(int limit, CancellationToken ct)
private IReadOnlyList<AiMarkupExampleDto> GetAiMarkupExamples(int limit)
{
var examples = new List<AiMarkupExampleDto>();
for (int index = _moves.Count - 1; index >= 0 && examples.Count < limit; index--)
@@ -429,31 +496,24 @@ public class FakeKanjStore : ICardStore
examples.Add(new AiMarkupExampleDto(sourceText, move.ToCol));
}
return Task.FromResult<IReadOnlyList<AiMarkupExampleDto>>(examples);
return examples;
}
/// <inheritdoc />
public Task<IReadOnlyList<string>> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
private IReadOnlyList<string> ListArchiveCandidates(DateTimeOffset receivedBeforeUtc)
{
var boardIds = new HashSet<string>(_boards.Select(board => board.Id), StringComparer.Ordinal);
IReadOnlyList<string> result = _cards
return _cards
.Where(card => (card.Col == KanbanColumns.Inbox || boardIds.Contains(card.Col))
&& ReceivedAtOf(card) < receivedBeforeUtc)
.Select(card => card.Id)
.ToList();
return Task.FromResult(result);
}
/// <inheritdoc />
public Task<int> ArchiveAsync(
IReadOnlyList<string> cardIds,
DateTimeOffset archivedAt,
CancellationToken ct)
private int Archive(IReadOnlyList<string> cardIds, DateTimeOffset archivedAt)
{
if (cardIds.Count == 0)
{
return Task.FromResult(0);
return 0;
}
var ids = new HashSet<string>(cardIds, StringComparer.Ordinal);
@@ -475,38 +535,32 @@ public class FakeKanjStore : ICardStore
archived++;
}
return Task.FromResult(archived);
return archived;
}
/// <inheritdoc />
public Task<IReadOnlyList<string>> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct)
private IReadOnlyList<string> ListExpiredArchiveCandidates(DateTimeOffset archivedBeforeUtc)
{
IReadOnlyList<string> result = _cards
return _cards
.Where(card => card.Col == KanbanColumns.Archive
&& _archivedAtById.TryGetValue(card.Id, out DateTimeOffset archivedAt)
&& archivedAt < archivedBeforeUtc)
.Select(card => card.Id)
.ToList();
return Task.FromResult(result);
}
/// <inheritdoc />
public Task<IReadOnlyList<string>> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct)
private IReadOnlyList<string> ListTrashCandidates(DateTimeOffset receivedBeforeUtc)
{
IReadOnlyList<string> result = _cards
return _cards
.Where(card => card.Col == KanbanColumns.Trash && ReceivedAtOf(card) < receivedBeforeUtc)
.Select(card => card.Id)
.ToList();
return Task.FromResult(result);
}
/// <inheritdoc />
public Task<int> PurgeAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
private int Purge(IReadOnlyList<string> cardIds)
{
// Жёсткое удаление пачки (как KanbanStore.PurgeAsync): комментарии — часть карточки, журнал не трогаем.
if (cardIds.Count == 0)
{
return Task.FromResult(0);
return 0;
}
var ids = new HashSet<string>(cardIds, StringComparer.Ordinal);
@@ -516,88 +570,45 @@ public class FakeKanjStore : ICardStore
_archivedAtById.Remove(cardId);
}
return Task.FromResult(removed);
return removed;
}
/// <summary>
/// Задаёт метку архивации карточки
/// </summary>
/// <param name="cardId">Id карточки.</param>
/// <param name="archivedAt">Метка архивации (когда карточка ушла в архив).</param>
public void SetArchivedAt(string cardId, DateTimeOffset archivedAt) => _archivedAtById[cardId] = archivedAt;
/// <summary>
/// Метка архивации карточки — проверка archived_at после автоархива тика.
/// </summary>
/// <param name="cardId">Id карточки.</param>
/// <returns>Метка архивации либо null — карточки нет/не архивирована/метка сброшена.</returns>
public DateTimeOffset? ArchivedAtOf(string cardId)
private IReadOnlyList<CardDto> ListCardsForConversion()
{
return _archivedAtById.TryGetValue(cardId, out DateTimeOffset archivedAt) ? archivedAt : null;
}
// Момент получения карточки из её epoch-ms (как маппинг адаптера ReceivedAt → ReceivedAtMs).
// card: Карточка.
// Возвращает: ReceivedAt карточки как UTC-момент.
private static DateTimeOffset ReceivedAtOf(CardDto card) => DateTimeOffset.FromUnixTimeMilliseconds(card.ReceivedAtMs);
/// <inheritdoc />
public Task<IReadOnlyList<CardDto>> ListCardsForConversionAsync(CancellationToken ct)
{
IReadOnlyList<CardDto> result = _cards
return _cards
.Where(card => card.Budget is not null
&& card.Col != KanbanColumns.Archive
&& card.Col != KanbanColumns.Trash)
.OrderByDescending(card => card.ReceivedAtMs)
.ToList();
return Task.FromResult(result);
}
/// <inheritdoc />
public Task UpdateConversionAsync(
string cardId,
double? convFrom,
double? convTo,
string convCur,
CancellationToken ct)
private void UpdateConversion(string cardId, double? convFrom, double? convTo, string convCur)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index >= 0)
{
_cards[index] = _cards[index] with
{
Converted = convCur.Length == 0
? null
: new CardBudgetDto(convFrom, convTo, convCur),
Converted = convCur.Length == 0 ? null : new CardBudgetDto(convFrom, convTo, convCur),
};
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<IReadOnlyList<CardDto>> ListSelectedCardsAsync(string? containerId, CancellationToken ct)
private IReadOnlyList<CardDto> ListSelectedCards(string? containerId)
{
IEnumerable<CardDto> query = containerId is null
? _cards.Where(card => CardsDefaultContainers.Contains(card.Col))
: _cards.Where(card => card.Col == containerId);
return Task.FromResult<IReadOnlyList<CardDto>>(
query.OrderByDescending(card => card.UpdatedAtMs).ToList());
return query.OrderByDescending(card => card.UpdatedAtMs).ToList();
}
/// <inheritdoc />
public Task<bool> PatchCardAsync(
string cardId,
CardPatch patch,
CancellationToken ct)
private bool PatchCard(string cardId, CardPatch patch)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0)
{
return Task.FromResult(false);
return false;
}
CardDto card = _cards[index];
@@ -647,19 +658,15 @@ public class FakeKanjStore : ICardStore
}
_cards[index] = card with { UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() };
return Task.FromResult(true);
return true;
}
/// <inheritdoc />
public Task<bool> AddLinkAsync(
string cardId,
CardLinkDto link,
CancellationToken ct)
private bool AddLink(string cardId, CardLinkDto link)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0)
{
return Task.FromResult(false);
return false;
}
_cards[index] = _cards[index] with
@@ -667,19 +674,15 @@ public class FakeKanjStore : ICardStore
Links = [.. _cards[index].Links, link],
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
return true;
}
/// <inheritdoc />
public Task<bool> RemoveLinkAsync(
string cardId,
string linkId,
CancellationToken ct)
private bool RemoveLink(string cardId, string linkId)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0)
{
return Task.FromResult(false);
return false;
}
_cards[index] = _cards[index] with
@@ -687,19 +690,15 @@ public class FakeKanjStore : ICardStore
Links = _cards[index].Links.Where(link => link.Id != linkId).ToList(),
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
return true;
}
/// <inheritdoc />
public Task<bool> AddFileAsync(
string cardId,
CardFileDto file,
CancellationToken ct)
private bool AddFile(string cardId, CardFileDto file)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0)
{
return Task.FromResult(false);
return false;
}
_cards[index] = _cards[index] with
@@ -707,19 +706,15 @@ public class FakeKanjStore : ICardStore
Files = [.. _cards[index].Files, file],
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
return true;
}
/// <inheritdoc />
public Task<bool> RemoveFileAsync(
string cardId,
string fileId,
CancellationToken ct)
private bool RemoveFile(string cardId, string fileId)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0)
{
return Task.FromResult(false);
return false;
}
_cards[index] = _cards[index] with
@@ -727,21 +722,15 @@ public class FakeKanjStore : ICardStore
Files = _cards[index].Files.Where(file => file.Id != fileId).ToList(),
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
};
return Task.FromResult(true);
return true;
}
/// <inheritdoc />
public Task<bool> MoveCardStageAsync(
string cardId,
string containerId,
CardHistoryDto historyEntry,
long atMs,
CancellationToken ct)
private bool MoveCardStage(string cardId, string containerId, CardHistoryDto historyEntry, long atMs)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0)
{
return Task.FromResult(false);
return false;
}
CardDto card = _cards[index];
@@ -753,19 +742,15 @@ public class FakeKanjStore : ICardStore
UpdatedAtMs = atMs,
History = [.. card.History, historyEntry],
};
return Task.FromResult(true);
return true;
}
/// <inheritdoc />
public Task SetReminderAsync(
string cardId,
long atMs,
CancellationToken ct)
private void SetReminder(string cardId, long atMs)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index < 0)
{
return Task.CompletedTask;
return;
}
_firedById.Remove(cardId);
@@ -774,11 +759,9 @@ public class FakeKanjStore : ICardStore
Reminder = new CardReminderDto(atMs),
UpdatedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(),
};
return Task.CompletedTask;
}
/// <inheritdoc />
public Task ClearReminderAsync(string cardId, CancellationToken ct)
private void ClearReminder(string cardId)
{
int index = _cards.FindIndex(card => card.Id == cardId);
if (index >= 0)
@@ -787,20 +770,14 @@ public class FakeKanjStore : ICardStore
}
_firedById.Remove(cardId);
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> ClearStageAsync(string containerId, CancellationToken ct)
{
return Task.FromResult(_cards.RemoveAll(card => card.Col == containerId));
}
private int ClearStage(string containerId) => _cards.RemoveAll(card => card.Col == containerId);
/// <inheritdoc />
public virtual Task<IReadOnlyList<CardReminderDueDto>> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct)
private IReadOnlyList<CardReminderDueDto> ListDueReminders(DateTimeOffset now)
{
long nowMs = now.ToUnixTimeMilliseconds();
IReadOnlyList<CardReminderDueDto> due = _cards
return _cards
.Where(card => card.Col == CardsDefaultContainers.Hold
&& card.Reminder is not null
&& !_firedById.Contains(card.Id)
@@ -808,11 +785,9 @@ public class FakeKanjStore : ICardStore
.OrderBy(card => card.Reminder!.At)
.Select(card => new CardReminderDueDto(card.Id, card.Title, card.Col))
.ToList();
return Task.FromResult(due);
}
/// <inheritdoc />
public Task MarkRemindersFiredAsync(IReadOnlyList<string> cardIds, CancellationToken ct)
private void MarkRemindersFired(IReadOnlyList<string> cardIds)
{
foreach (string cardId in cardIds)
{
@@ -821,12 +796,9 @@ public class FakeKanjStore : ICardStore
_firedById.Add(cardId);
}
}
return Task.CompletedTask;
}
/// <inheritdoc />
public Task<int> ClearExpiredRemindersAsync(DateTimeOffset now, CancellationToken ct)
private int ClearExpiredReminders(DateTimeOffset now)
{
long nowMs = now.ToUnixTimeMilliseconds();
int cleared = 0;
@@ -840,16 +812,16 @@ public class FakeKanjStore : ICardStore
}
}
return Task.FromResult(cleared);
return cleared;
}
/// <inheritdoc />
public Task<IReadOnlyList<CardDto>> ListInboxWithSourceAsync(CancellationToken ct)
private IReadOnlyList<CardDto> ListInboxWithSource()
{
IReadOnlyList<CardDto> result = _cards
return _cards
.Where(card => card.Col == KanbanColumns.Inbox && (card.Content.Text ?? string.Empty) != string.Empty)
.OrderByDescending(card => card.ReceivedAtMs)
.ToList();
return Task.FromResult(result);
}
private static DateTimeOffset ReceivedAtOf(CardDto card) => DateTimeOffset.FromUnixTimeMilliseconds(card.ReceivedAtMs);
}