SaaS-мониторинг Telegram: ядро (модули Cards/Kanban/Pipeline/Tenants/Settings/ Discovery, Api, Infrastructure), сервисы telegram/ai/ml/storage, фронт Vue, контракты и grpc-hosting, деплой-конфиги (dev/prod/observability/CI-раннер), Gitea Actions CI, документация (ТЗ, техдок, api-map, код-стайл, планы, бэклог). Текущее состояние: все этапы роадмапа 0–12 закрыты, сборка 5 sln 0/0, тесты 1340/130/52/38/9 зелёные.
This commit is contained in:
@@ -0,0 +1,792 @@
|
||||
using Deal.Contracts.Integrations.Models;
|
||||
using Deal.Modules.Cards.Application.Sources;
|
||||
using Deal.Modules.Kanban.Application.Models;
|
||||
using Deal.Modules.Kanban.Application.Services;
|
||||
using Deal.Tests.Unit.Modules.Cards;
|
||||
using Deal.Tests.Unit.Modules.Settings;
|
||||
|
||||
namespace Deal.Tests.Unit.Contracts;
|
||||
|
||||
/// <summary>
|
||||
/// Тесты CardsService — карточки
|
||||
/// </summary>
|
||||
public sealed class CardsServiceTests
|
||||
{
|
||||
|
||||
[Fact]
|
||||
public async Task ListCards_NoCol_ReturnsAllOrderedByReceivedAtDesc()
|
||||
{
|
||||
(CardsService service, FakeKanjStore 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, FakeKanjStore 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, FakeKanjStore 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, FakeKanjStore 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, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore 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, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore store, _, FakeMlClient 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, _, _, FakeMlClient 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, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore 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, FakeKanjStore 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, FakeKanjStore 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, FakeKanjStore 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, FakeKanjStore 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, FakeKanjStore 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, FakeKanjStore 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, FakeKanjStore 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, FakeKanjStore 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, FakeKanjStore 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, FakeKanjStore store, _, FakeMlClient 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, FakeKanjStore 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, FakeKanjStore 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, FakeKanjStore 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, FakeKanjStore 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, FakeKanjStore Store, FakeSettingsStore Settings, FakeMlClient Ml) Create()
|
||||
{
|
||||
var store = new FakeKanjStore();
|
||||
var settings = new FakeSettingsStore();
|
||||
var ml = new FakeMlClient();
|
||||
return (new CardsService(store, settings, ml, new FakeFileStorage()), 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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user