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