793 lines
33 KiB
C#
793 lines
33 KiB
C#
using Deal.Contracts.Integrations.Models;
|
||
using Deal.Tests.Unit.Support;
|
||
using Deal.Modules.Cards.Application.Sources;
|
||
using Deal.Modules.Kanban.Application.Models;
|
||
using Deal.Modules.Kanban.Application.Services;
|
||
using Deal.Tests.Unit.Modules.Settings;
|
||
|
||
namespace Deal.Tests.Unit.Contracts;
|
||
|
||
/// <summary>
|
||
/// Тесты CardsService — карточки
|
||
/// </summary>
|
||
public sealed class CardsServiceTests
|
||
{
|
||
|
||
[Fact]
|
||
public async Task ListCards_NoCol_ReturnsAllOrderedByReceivedAtDesc()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||
store.SeedCard(Card("l_1", "inbox", receivedAtMs: 100));
|
||
store.SeedCard(Card("l_2", "b_x", receivedAtMs: 300));
|
||
|
||
IReadOnlyList<CardDto> cards = await service.ListCardsAsync(col: null, CancellationToken.None);
|
||
|
||
// Без фильтра — все карточки дашборда; порядок — received_at DESC.
|
||
Assert.Equal(new[] { "l_2", "l_1" }, cards.Select(card => card.Id));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ListCards_ByColumn_ReturnsOnlyColumnCards()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||
store.SeedCard(Card("l_1", "inbox"));
|
||
store.SeedCard(Card("l_2", KanbanColumns.Trash));
|
||
|
||
IReadOnlyList<CardDto> cards = await service.ListCardsAsync(KanbanColumns.Trash, CancellationToken.None);
|
||
|
||
CardDto card = Assert.Single(cards);
|
||
Assert.Equal("l_2", card.Id);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetCard_Existing_ReturnsCard()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||
store.SeedCard(Card("l_1", "inbox", title: "Middle Python"));
|
||
|
||
CardDto? card = await service.GetCardAsync("l_1", CancellationToken.None);
|
||
|
||
Assert.NotNull(card);
|
||
Assert.Equal("Middle Python", card!.Title);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task GetCard_Missing_ReturnsNull()
|
||
{
|
||
(CardsService service, _, _, _) = Create();
|
||
|
||
CardDto? card = await service.GetCardAsync("l_missing", CancellationToken.None);
|
||
|
||
Assert.Null(card);
|
||
}
|
||
|
||
|
||
[Fact]
|
||
public async Task Move_ToBoardWithRules_UpdatesColumnAndComputesMatchHits()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
|
||
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Middle Python-разработчик на бота, 1600$."));
|
||
|
||
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None);
|
||
|
||
Assert.Null(result.Error);
|
||
Assert.Equal("b_py", result.Card!.Col);
|
||
Assert.False(result.Card.IsNew); // перенос снимает «новое»
|
||
Assert.Equal("inbox", result.Card.PrevCol); // prev_col = прежняя колонка
|
||
MatchHitDto hit = Assert.Single(result.Card.MatchHits); // «почему в колонке» (Ruling 2)
|
||
Assert.Equal("Стек", hit.Label);
|
||
Assert.Equal("python", hit.Term);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Move_ToBoard_WritesJournalAndPush()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
|
||
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
|
||
|
||
await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None);
|
||
|
||
CardMoveDto move = Assert.Single(store.Moves); // журнал learning_log (Ruling 4)
|
||
Assert.StartsWith("lm_", move.Id);
|
||
Assert.Equal("l_1", move.LeadId);
|
||
Assert.Equal("move", move.Action);
|
||
Assert.Equal("inbox", move.FromCol);
|
||
Assert.Equal("b_py", move.ToCol);
|
||
(string text, string label, double delta) = Assert.Single(ml.Pushed); // обучение всегда (Ruling 4)
|
||
Assert.Equal("Нужен Python-разработчик", text);
|
||
Assert.Equal("b_py", label);
|
||
Assert.Equal(1.0, delta);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Move_ToInbox_WritesJournalWithoutPush()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
|
||
store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", prevCol: "inbox"));
|
||
|
||
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "inbox", CancellationToken.None);
|
||
|
||
Assert.Null(result.Error);
|
||
Assert.Equal("inbox", result.Card!.Col);
|
||
CardMoveDto move = Assert.Single(store.Moves);
|
||
Assert.Equal("move", move.Action);
|
||
Assert.Equal("b_py", move.FromCol);
|
||
Assert.Equal("inbox", move.ToCol);
|
||
Assert.Empty(ml.Pushed); // «Неразобранное» не учим (Ruling 4, L188)
|
||
Assert.Empty(result.Card.MatchHits); // inbox — служебная колонка, hits пусто (Ruling 2)
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Move_ToUnknownBoard_Returns400Text()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
|
||
|
||
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_ghost", CancellationToken.None);
|
||
|
||
Assert.Equal(CardsService.MoveTargetInvalidDetail, result.Error); // 400 «Переносить можно только…»
|
||
Assert.Null(result.Card);
|
||
Assert.Empty(store.Moves);
|
||
Assert.Empty(ml.Pushed);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Move_UnknownBoardAndMissingCard_Returns400TextFirst()
|
||
{
|
||
(CardsService service, _, _, _) = Create();
|
||
|
||
CardResultDto result = await service.MoveDashboardCardAsync("l_ghost", "b_ghost", CancellationToken.None);
|
||
|
||
Assert.Equal(CardsService.MoveTargetInvalidDetail, result.Error);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Move_CardMissing_ReturnsNullLead()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||
store.SeedBoard(Board("b_py"));
|
||
|
||
CardResultDto result = await service.MoveDashboardCardAsync("l_ghost", "b_py", CancellationToken.None);
|
||
|
||
Assert.Null(result.Error);
|
||
Assert.Null(result.Card); // эндпоинт отвечает 404 «Карточка не найдена»
|
||
Assert.Empty(store.Moves);
|
||
Assert.Empty(ml.Pushed);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Move_SameColumn_NoOpWithoutJournalAndPush()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
|
||
store.SeedCard(Card("l_1", "b_py", sourceMsg: "Нужен Python-разработчик", isNew: true));
|
||
|
||
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None);
|
||
|
||
Assert.Null(result.Error);
|
||
Assert.NotNull(result.Card);
|
||
Assert.Equal("b_py", result.Card!.Col);
|
||
Assert.True(result.Card.IsNew); // флаг не тронут
|
||
Assert.Empty(store.Moves);
|
||
Assert.Empty(ml.Pushed);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Move_ToBoardWithoutRules_MatchHitsEmptyButPushSent()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||
store.SeedBoard(Board("b_free")); // правил нет
|
||
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Любой текст"));
|
||
|
||
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_free", CancellationToken.None);
|
||
|
||
Assert.Null(result.Error);
|
||
Assert.Equal("b_free", result.Card!.Col);
|
||
Assert.Empty(result.Card.MatchHits); // доска без активных правил → [] (Ruling 2)
|
||
Assert.Single(ml.Pushed);
|
||
Assert.Equal("b_free", Assert.Single(ml.Pushed).Label);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Move_FromBoardToBoard_PrevColIsOldBoardAndPushNewLabel()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||
store.SeedBoard(Board("b_java", Rules(stack: new[] { "java" })));
|
||
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
|
||
store.SeedCard(Card("l_1", "b_java", sourceMsg: "Нужен Java-разработчик", prevCol: "inbox"));
|
||
|
||
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None);
|
||
|
||
Assert.Equal("b_py", result.Card!.Col);
|
||
Assert.Equal("b_java", result.Card.PrevCol);
|
||
CardMoveDto move = Assert.Single(store.Moves);
|
||
Assert.Equal("b_java", move.FromCol);
|
||
Assert.Equal("b_py", move.ToCol);
|
||
(string _, string label, double _) = Assert.Single(ml.Pushed);
|
||
Assert.Equal("b_py", label); // учим НОВУЮ колонку (L188–191)
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Move_EmptySourceText_UsesTitleForMatchHitsAndPush()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
|
||
store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", sourceMsg: " "));
|
||
|
||
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None);
|
||
|
||
Assert.Single(result.Card!.MatchHits);
|
||
(string text, string _, double _) = Assert.Single(ml.Pushed);
|
||
Assert.Equal("Middle Python-разработчик", text);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Move_EmptySourceAndTitle_JournalWithoutPush()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||
store.SeedBoard(Board("b_py"));
|
||
store.SeedCard(Card("l_1", "inbox"));
|
||
|
||
await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None);
|
||
|
||
Assert.Single(store.Moves); // перенос записан
|
||
Assert.Empty(ml.Pushed); // пустой текст не учим (L190–191: if text)
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Move_ToBoardWithGradeAndBudget_HitsIncludeGradeWordAndBudget()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||
store.SeedBoard(Board("b_mid", Rules(
|
||
grade: new[] { "middle" },
|
||
budget: new BudgetRangeDto(1000, 3000, "USD"))));
|
||
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен мидл разработчик, 1 600–2 200$."));
|
||
|
||
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_mid", CancellationToken.None);
|
||
|
||
Assert.Equal(2, result.Card!.MatchHits.Count);
|
||
MatchHitDto grade = result.Card.MatchHits[0];
|
||
Assert.Equal("Грейд/уровень", grade.Label);
|
||
Assert.Equal("middle", grade.Term);
|
||
Assert.Equal("мидл", grade.Word); // найденный синоним
|
||
MatchHitDto budget = result.Card.MatchHits[1];
|
||
Assert.Equal("Бюджет", budget.Label); // сумма в USD в диапазоне 1000–3000 USD
|
||
}
|
||
|
||
// ─── Move из служебных колонок запрещён (code-review: вывод — только restore) ──
|
||
|
||
[Fact]
|
||
public async Task Move_FromArchive_Returns400AndWritesNothing()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||
store.SeedBoard(Board("b_py"));
|
||
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая вакансия", prevCol: "b_py"));
|
||
|
||
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None);
|
||
|
||
Assert.Equal(CardsService.MoveSourceRestrictedDetail, result.Error);
|
||
Assert.Null(result.Card);
|
||
Assert.Equal(KanbanColumns.Archive, store.CardDtos.Single().Col); // карточка не тронута
|
||
Assert.Empty(store.Moves); // журнал не пишется
|
||
Assert.Empty(ml.Pushed); // обучение не шлётся
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Move_FromTrash_Returns400AndWritesNothing()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||
store.SeedBoard(Board("b_py"));
|
||
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Спам-текст", prevCol: "b_py"));
|
||
|
||
CardResultDto result = await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None);
|
||
|
||
Assert.Equal(CardsService.MoveSourceRestrictedDetail, result.Error);
|
||
Assert.Null(result.Card);
|
||
Assert.Equal(KanbanColumns.Trash, store.CardDtos.Single().Col);
|
||
Assert.Empty(store.Moves);
|
||
Assert.Empty(ml.Pushed);
|
||
}
|
||
|
||
|
||
[Fact]
|
||
public async Task Trash_FromInbox_MovesToTrashWithJournalAndSpamPush()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Предлагаю услуги продвижения", isNew: true));
|
||
|
||
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
|
||
|
||
Assert.Equal(KanbanColumns.Trash, result!.Col);
|
||
Assert.False(result.IsNew);
|
||
Assert.Equal("inbox", result.PrevCol);
|
||
Assert.Empty(result.MatchHits); // корзина — служебная, hits пусто (Ruling 2)
|
||
CardMoveDto move = Assert.Single(store.Moves);
|
||
Assert.Equal("trash", move.Action);
|
||
Assert.Equal("inbox", move.FromCol);
|
||
Assert.Equal(KanbanColumns.Trash, move.ToCol);
|
||
(string text, string label, double delta) = Assert.Single(ml.Pushed); // «в корзину» = спам (Ruling 4)
|
||
Assert.Equal("Предлагаю услуги продвижения", text);
|
||
Assert.Equal("spam", label);
|
||
Assert.Equal(1.0, delta);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Trash_AlreadyInTrash_NoOpWithoutJournalAndPush()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст"));
|
||
|
||
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
|
||
|
||
Assert.NotNull(result);
|
||
Assert.Empty(store.Moves); // _move L165–166: в ту же колонку не переносим
|
||
Assert.Empty(ml.Pushed);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Trash_FromArchive_WritesJournalWithoutPush()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py"));
|
||
|
||
CardDto? result = await service.TrashCardAsync("l_1", CancellationToken.None);
|
||
|
||
Assert.Equal(KanbanColumns.Trash, result!.Col);
|
||
CardMoveDto move = Assert.Single(store.Moves);
|
||
Assert.Equal("trash", move.Action);
|
||
Assert.Equal(KanbanColumns.Archive, move.FromCol);
|
||
Assert.Empty(ml.Pushed); // карточка уже в archive — не «переучиваем» на спам (L198)
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Trash_CardMissing_ReturnsNull()
|
||
{
|
||
(CardsService service, _, _, TestMlClient ml) = Create();
|
||
|
||
CardDto? result = await service.TrashCardAsync("l_ghost", CancellationToken.None);
|
||
|
||
Assert.Null(result); // эндпоинт отвечает 404 «Карточка не найдена»
|
||
Assert.Empty(ml.Pushed);
|
||
}
|
||
|
||
|
||
[Fact]
|
||
public async Task Restore_FromTrash_ToPrevColBoard_UnlearnsSpam()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
|
||
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Нужен Python-разработчик", prevCol: "b_py"));
|
||
|
||
string? back = await service.RestoreCardAsync("l_1", CancellationToken.None);
|
||
|
||
Assert.Equal("b_py", back); // ответ {ok, col}
|
||
CardDto card = Assert.Single(store.CardDtos);
|
||
Assert.Equal("b_py", card.Col);
|
||
Assert.True(card.IsNew); // возврат — снова «новое» (L213)
|
||
Assert.Equal("inbox", card.PrevCol); // restore ставит prev_col='inbox' (Ruling 10)
|
||
Assert.Single(card.MatchHits); // hits пересчитаны для доски (Ruling 2)
|
||
CardMoveDto move = Assert.Single(store.Moves);
|
||
Assert.Equal("restore", move.Action);
|
||
Assert.Equal(KanbanColumns.Trash, move.FromCol);
|
||
Assert.Equal("b_py", move.ToCol);
|
||
(string text, string label, double delta) = Assert.Single(ml.Pushed); // возврат из корзины = не спам (L218–221)
|
||
Assert.Equal("Нужен Python-разработчик", text);
|
||
Assert.Equal("spam", label);
|
||
Assert.Equal(-1.0, delta);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Restore_FromArchive_NoPush()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||
store.SeedBoard(Board("b_py"));
|
||
store.SeedCard(Card("l_1", KanbanColumns.Archive, sourceMsg: "Старая карточка", prevCol: "b_py"));
|
||
|
||
string? back = await service.RestoreCardAsync("l_1", CancellationToken.None);
|
||
|
||
Assert.Equal("b_py", back);
|
||
CardMoveDto move = Assert.Single(store.Moves);
|
||
Assert.Equal("restore", move.Action);
|
||
Assert.Equal(KanbanColumns.Archive, move.FromCol);
|
||
Assert.Empty(ml.Pushed); // только возврат ИЗ корзины снимает метку spam (L218)
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Restore_FromTrash_PrevColDeletedBoard_FallsBackToInbox()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст", prevCol: "b_gone"));
|
||
|
||
string? back = await service.RestoreCardAsync("l_1", CancellationToken.None);
|
||
|
||
Assert.Equal("inbox", back);
|
||
CardDto card = Assert.Single(store.CardDtos);
|
||
Assert.Equal("inbox", card.Col);
|
||
Assert.Empty(card.MatchHits);
|
||
Assert.Equal(-1.0, Assert.Single(ml.Pushed).Delta); // из корзины — сигнал всё равно снимается
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Restore_FromTrash_PrevColInbox_ReturnsInbox()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||
store.SeedCard(Card("l_1", KanbanColumns.Trash, sourceMsg: "Текст", prevCol: "inbox"));
|
||
|
||
string? back = await service.RestoreCardAsync("l_1", CancellationToken.None);
|
||
|
||
Assert.Equal("inbox", back);
|
||
CardDto card = Assert.Single(store.CardDtos);
|
||
Assert.Equal("inbox", card.Col);
|
||
Assert.True(card.IsNew);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Restore_CardMissing_ReturnsNull()
|
||
{
|
||
(CardsService service, _, _, _) = Create();
|
||
|
||
string? back = await service.RestoreCardAsync("l_ghost", CancellationToken.None);
|
||
|
||
Assert.Null(back); // эндпоинт отвечает 404 «Карточка не найдена»
|
||
}
|
||
|
||
|
||
[Fact]
|
||
public async Task DeleteForever_RemovesCardAndComments_KeepsJournal()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||
store.SeedCard(Card("l_1", KanbanColumns.Trash,
|
||
sourceMsg: "Нужен Python-разработчик",
|
||
comments: [new CardCommentDto("cm_1", "Вы", "Перезвонить", "5 мин")]));
|
||
|
||
bool deleted = await service.DeleteForeverAsync("l_1", CancellationToken.None);
|
||
|
||
Assert.True(deleted);
|
||
Assert.DoesNotContain(store.CardDtos, card => card.Id == "l_1"); // карточка удалена
|
||
Assert.Empty(store.CardDtos);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task DeleteForever_JournalRowsSurviveCardDeletion()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||
store.SeedBoard(Board("b_py", Rules(stack: new[] { "python" })));
|
||
store.SeedCard(Card("l_1", "inbox", sourceMsg: "Нужен Python-разработчик"));
|
||
|
||
await service.MoveDashboardCardAsync("l_1", "b_py", CancellationToken.None); // журнал: 1 запись move
|
||
await service.DeleteForeverAsync("l_1", CancellationToken.None);
|
||
|
||
CardMoveDto move = Assert.Single(store.Moves);
|
||
Assert.Equal("move", move.Action);
|
||
Assert.Equal("l_1", move.LeadId);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task DeleteForever_CardMissing_ReturnsFalse()
|
||
{
|
||
(CardsService service, _, _, _) = Create();
|
||
|
||
bool deleted = await service.DeleteForeverAsync("l_ghost", CancellationToken.None);
|
||
|
||
Assert.False(deleted); // эндпоинт отвечает 404 «Карточка не найдена»
|
||
}
|
||
|
||
|
||
[Fact]
|
||
public async Task ClearCol_Trash_ReturnsClearedCount()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||
store.SeedCard(Card("l_1", KanbanColumns.Trash));
|
||
store.SeedCard(Card("l_2", KanbanColumns.Trash));
|
||
store.SeedCard(Card("l_3", KanbanColumns.Archive));
|
||
|
||
ClearColResultDto result = await service.ClearColAsync(KanbanColumns.Trash, CancellationToken.None);
|
||
|
||
Assert.Null(result.Error);
|
||
Assert.Equal(2, result.Cleared);
|
||
Assert.DoesNotContain(store.CardDtos, card => card.Col == KanbanColumns.Trash);
|
||
Assert.Single(store.CardDtos); // архив не тронут
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ClearCol_EmptyTrash_ReturnsZero()
|
||
{
|
||
(CardsService service, _, _, _) = Create();
|
||
|
||
ClearColResultDto result = await service.ClearColAsync(KanbanColumns.Trash, CancellationToken.None);
|
||
|
||
Assert.Null(result.Error);
|
||
Assert.Equal(0, result.Cleared);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ClearCol_Board_Returns400Text()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||
store.SeedBoard(Board("b_py"));
|
||
store.SeedCard(Card("l_1", "b_py"));
|
||
|
||
ClearColResultDto result = await service.ClearColAsync("b_py", CancellationToken.None);
|
||
|
||
Assert.Equal(CardsService.ClearColInvalidDetail, result.Error); // «Очищать можно только корзину или архив»
|
||
Assert.Single(store.CardDtos); // ничего не удалено
|
||
}
|
||
|
||
[Fact]
|
||
public async Task ClearCol_Inbox_Returns400Text()
|
||
{
|
||
(CardsService service, _, _, _) = Create();
|
||
|
||
ClearColResultDto result = await service.ClearColAsync(KanbanColumns.Inbox, CancellationToken.None);
|
||
|
||
Assert.Equal(CardsService.ClearColInvalidDetail, result.Error);
|
||
}
|
||
|
||
|
||
[Fact]
|
||
public async Task MarkSeen_ById_OnlyThatCard()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||
store.SeedCard(Card("l_1", "inbox", isNew: true));
|
||
store.SeedCard(Card("l_2", "inbox", isNew: true));
|
||
|
||
await service.MarkSeenAsync("l_1", null, CancellationToken.None);
|
||
|
||
Assert.False(Assert.Single(store.CardDtos, card => card.Id == "l_1").IsNew);
|
||
Assert.True(Assert.Single(store.CardDtos, card => card.Id == "l_2").IsNew);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task MarkSeen_ByCol_OnlyColumnCards()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||
store.SeedCard(Card("l_1", "inbox", isNew: true));
|
||
store.SeedCard(Card("l_2", "inbox", isNew: true));
|
||
store.SeedCard(Card("l_3", "b_py", isNew: true));
|
||
|
||
await service.MarkSeenAsync(null, "inbox", CancellationToken.None);
|
||
|
||
Assert.DoesNotContain(store.CardDtos, card => card.Col == "inbox" && card.IsNew);
|
||
Assert.True(Assert.Single(store.CardDtos, card => card.Id == "l_3").IsNew);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task MarkSeen_All_WithoutParameters()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||
store.SeedCard(Card("l_1", "inbox", isNew: true));
|
||
store.SeedCard(Card("l_2", KanbanColumns.Trash, isNew: true));
|
||
|
||
await service.MarkSeenAsync(null, null, CancellationToken.None);
|
||
|
||
Assert.All(store.CardDtos, card => Assert.False(card.IsNew));
|
||
}
|
||
|
||
|
||
[Fact]
|
||
public async Task AddComment_EmptyOrWhitespaceText_Returns400Text()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||
store.SeedCard(Card("l_1", "inbox"));
|
||
|
||
AddCommentResultDto empty = await service.AddCommentAsync("l_1", " ", CancellationToken.None);
|
||
AddCommentResultDto nullText = await service.AddCommentAsync("l_1", string.Empty, CancellationToken.None);
|
||
|
||
Assert.Equal(CardsService.EmptyCommentDetail, empty.Error); // «Пустой комментарий»
|
||
Assert.Equal(CardsService.EmptyCommentDetail, nullText.Error);
|
||
Assert.Empty(store.Moves);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task AddComment_Valid_AppendsTrimmedCommentWithJournal()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||
store.SeedCard(Card("l_1", "inbox"));
|
||
|
||
AddCommentResultDto result = await service.AddCommentAsync("l_1", " Перезвонить завтра ", CancellationToken.None);
|
||
|
||
Assert.Null(result.Error);
|
||
CardCommentDto comment = Assert.Single(result.Comments!);
|
||
Assert.StartsWith("cm_", comment.Id); // id комментария (Ruling 12)
|
||
Assert.Equal("Вы", comment.By);
|
||
Assert.Equal("Перезвонить завтра", comment.Text); // Trim (L262)
|
||
Assert.Equal("только что", comment.Time);
|
||
CardMoveDto move = Assert.Single(store.Moves); // комментарий — тоже действие журнала (L264)
|
||
Assert.Equal("comment", move.Action);
|
||
Assert.Null(move.FromCol);
|
||
Assert.Null(move.ToCol);
|
||
Assert.Single(Assert.Single(store.CardDtos).Comments); // комментарий сохранён на карточке
|
||
}
|
||
|
||
[Fact]
|
||
public async Task AddComment_CardMissing_ReturnsNullComments()
|
||
{
|
||
(CardsService service, _, _, _) = Create();
|
||
|
||
AddCommentResultDto result = await service.AddCommentAsync("l_ghost", "Текст", CancellationToken.None);
|
||
|
||
Assert.Null(result.Error);
|
||
Assert.Null(result.Comments); // эндпоинт отвечает 404 «Карточка не найдена»
|
||
}
|
||
|
||
|
||
[Fact]
|
||
public async Task Counts_ColumnsNewAndMlStats()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, TestMlClient ml) = Create();
|
||
store.SeedCard(Card("l_1", "inbox", isNew: true));
|
||
store.SeedCard(Card("l_2", "inbox"));
|
||
store.SeedCard(Card("l_3", "b_py"));
|
||
store.SeedCard(Card("l_4", KanbanColumns.Trash, isNew: true));
|
||
ml.Status = MlStatus(learning: 7, ml: 2, ai: 3);
|
||
|
||
CardCountsDto counts = await service.CountsAsync(CancellationToken.None);
|
||
|
||
Assert.Equal(2, counts.New); // top-level new = сумма по колонкам (L274)
|
||
Assert.Equal(new CardColumnCountDto(2, 1), counts.Columns["inbox"]);
|
||
Assert.Equal(new CardColumnCountDto(1, 0), counts.Columns["b_py"]);
|
||
Assert.Equal(new CardColumnCountDto(1, 1), counts.Columns[KanbanColumns.Trash]);
|
||
Assert.Equal(7, counts.Learning); // из IMlClient.StatusAsync (план L321)
|
||
Assert.Equal(2, counts.Ml);
|
||
Assert.Equal(3, counts.Ai);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Counts_EmptyStore_ZeroNewAndStats()
|
||
{
|
||
(CardsService service, _, _, _) = Create();
|
||
|
||
CardCountsDto counts = await service.CountsAsync(CancellationToken.None);
|
||
|
||
Assert.Equal(0, counts.New);
|
||
Assert.Empty(counts.Columns);
|
||
Assert.Equal(0, counts.Learning);
|
||
}
|
||
|
||
|
||
[Fact]
|
||
public async Task Search_QueryShorterThanTwoChars_ReturnsEmptyAndDoesNotCallStore()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||
store.SeedCard(Card("l_1", "inbox", title: "Python-разработчик"));
|
||
|
||
IReadOnlyList<CardDto> one = await service.SearchCardsAsync("p", CancellationToken.None);
|
||
IReadOnlyList<CardDto> blank = await service.SearchCardsAsync(" ", CancellationToken.None);
|
||
|
||
Assert.Empty(one); // min 2 символа (L511–512)
|
||
Assert.Empty(blank);
|
||
Assert.Empty(store.SearchCalls); // порт не вызывается — поиск не имеет смысла
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Search_DelegatesToStoreWithQueryAndLimit()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||
store.SeedCard(Card("l_1", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300));
|
||
|
||
IReadOnlyList<CardDto> result = await service.SearchCardsAsync(" pYtHoN ", CancellationToken.None);
|
||
|
||
CardDto card = Assert.Single(result);
|
||
Assert.Equal("l_1", card.Id);
|
||
(string query, int limit) = Assert.Single(store.SearchCalls);
|
||
Assert.Equal("python", query);
|
||
Assert.Equal(12, limit);
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Search_FindsByTitleSummaryContactSourceText_OrderedByReceivedAtDesc()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||
store.SeedCard(Card("l_title", "inbox", title: "Middle Python-разработчик", receivedAtMs: 300));
|
||
store.SeedCard(Card("l_summary", "b_py", summary: "проект на python", receivedAtMs: 200));
|
||
store.SeedCard(Card("l_contact", "inbox", contact: "@python_dev", receivedAtMs: 100));
|
||
store.SeedCard(Card("l_msg", "inbox", sourceMsg: "Нужен GO-разработчик", receivedAtMs: 400));
|
||
|
||
IReadOnlyList<CardDto> result = await service.SearchCardsAsync("pYtHoN", CancellationToken.None);
|
||
|
||
// Регистр не важен (lower LIKE %q%), порядок — received_at DESC.
|
||
Assert.Equal(new[] { "l_title", "l_summary", "l_contact" }, result.Select(card => card.Id));
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Search_LimitIsTwelve()
|
||
{
|
||
(CardsService service, TestKanjStore store, _, _) = Create();
|
||
for (int i = 0; i < 15; i++)
|
||
{
|
||
store.SeedCard(Card($"l_{i:00}", "inbox", title: $"python {i}", receivedAtMs: i));
|
||
}
|
||
|
||
IReadOnlyList<CardDto> result = await service.SearchCardsAsync("python", CancellationToken.None);
|
||
|
||
Assert.Equal(12, result.Count); // limit=12 (L509)
|
||
Assert.Equal("l_14", result[0].Id); // свежие сверху
|
||
Assert.Equal("l_03", result[^1].Id); // 12 из 15 самых свежих
|
||
}
|
||
|
||
// ─── Хелперы ──────────────────────────────────────────────────────────
|
||
|
||
private static (CardsService Service, TestKanjStore Store, TestSettingsStore Settings, TestMlClient Ml) Create()
|
||
{
|
||
var store = new TestKanjStore();
|
||
var settings = new TestSettingsStore();
|
||
var ml = new TestMlClient();
|
||
return (new CardsService(store.Store, settings.Store, ml.Client, new TestFileStorage().Storage), store, settings, ml);
|
||
}
|
||
|
||
// Доска с правилами (ContainerRulesDto) либо без них.
|
||
private static ContainerDto Board(string id, ContainerRulesDto? rules = null) =>
|
||
new() { Id = id, Name = id, Rules = rules };
|
||
|
||
// Правила колонки с непустыми группами из параметров (как в ColumnRulesTests).
|
||
private static ContainerRulesDto Rules(
|
||
string mode = "all",
|
||
string[]? stack = null,
|
||
string[]? grade = null,
|
||
BudgetRangeDto? budget = null)
|
||
{
|
||
return new ContainerRulesDto(
|
||
mode,
|
||
Array.Empty<string>(),
|
||
Array.Empty<string>(),
|
||
stack ?? Array.Empty<string>(),
|
||
grade ?? Array.Empty<string>(),
|
||
Array.Empty<string>(),
|
||
budget);
|
||
}
|
||
|
||
// Карточка с полями по умолчанию (col=inbox, isNew=false, prevCol=inbox).
|
||
private static CardDto Card(
|
||
string id,
|
||
string col,
|
||
bool isNew = false,
|
||
string title = "",
|
||
string summary = "",
|
||
string contact = "",
|
||
string sourceMsg = "",
|
||
string prevCol = KanbanColumns.Inbox,
|
||
long receivedAtMs = 0,
|
||
IReadOnlyList<CardCommentDto>? comments = null)
|
||
{
|
||
return new CardDto
|
||
{
|
||
Id = id,
|
||
Col = col,
|
||
IsNew = isNew,
|
||
Title = title,
|
||
Summary = summary,
|
||
Contact = contact,
|
||
Content = new SourceContent { Text = sourceMsg },
|
||
PrevCol = prevCol,
|
||
ReceivedAtMs = receivedAtMs,
|
||
Comments = comments ?? Array.Empty<CardCommentDto>(),
|
||
};
|
||
}
|
||
|
||
// Ответ IMlClient.StatusAsync с заданными счётчиками (learning/ml/ai) для counts.
|
||
private static MlStatusResponseDto MlStatus(
|
||
int learning,
|
||
int ml,
|
||
int ai)
|
||
{
|
||
var service = new MlServiceStatusDto(
|
||
Ready: false,
|
||
Classes: new Dictionary<string, double>(),
|
||
Learned: 0,
|
||
Eval: new MlEvalDto(Count: 0, Correct: 0, Accuracy: 0.0));
|
||
var stats = new MlStatsDto(
|
||
Ml: ml,
|
||
Ai: ai,
|
||
Learning: learning,
|
||
Ready: false,
|
||
Classes: new Dictionary<string, double>(),
|
||
Learned: 0,
|
||
Reachable: true,
|
||
Outbox: 0);
|
||
return new MlStatusResponseDto(Enabled: true, Service: service, Reachable: true, Stats: stats);
|
||
}
|
||
}
|