Перевести «не найдено» карточек на NotFoundException

CardsService (get/move/links/reminders) и CardMover бросают NotFoundException вместо null/CardResultDto(null,null)/Exists=false. CardMoveResultDto — только Error; эндпоинты без локальных 404-проверок.
This commit is contained in:
2026-09-13 18:23:53 +03:00
parent a9bfecf9cc
commit d2d6b81aa0
12 changed files with 107 additions and 152 deletions
@@ -202,9 +202,7 @@ public static class CardDetailsEndpoints
return EndpointResults.BadRequest(result.Error); return EndpointResults.BadRequest(result.Error);
} }
return result.Card is null return await ReadCardAsync(context, cardId, ct);
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, cardId, ct);
} }
// DELETE /api/cards/{cardId}/links/{linkId}: удалить ссылку. Ответ — карточка. // DELETE /api/cards/{cardId}/links/{linkId}: удалить ссылку. Ответ — карточка.
@@ -220,10 +218,8 @@ public static class CardDetailsEndpoints
} }
CardsService service = context.RequestServices.GetRequiredService<CardsService>(); CardsService service = context.RequestServices.GetRequiredService<CardsService>();
CardResultDto result = await service.RemoveLinkAsync(cardId, linkId, ct); await service.RemoveLinkAsync(cardId, linkId, ct);
return result.Card is null return await ReadCardAsync(context, cardId, ct);
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, cardId, ct);
} }
// POST /api/cards/{cardId}/files: загрузка вложений (multipart/form-data, поле files). // POST /api/cards/{cardId}/files: загрузка вложений (multipart/form-data, поле files).
@@ -240,10 +236,7 @@ public static class CardDetailsEndpoints
} }
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
if (await cardsService.GetCardAsync(cardId, ct) is null) await cardsService.GetCardAsync(cardId, ct);
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
IFormCollection form; IFormCollection form;
try try
@@ -357,9 +350,7 @@ public static class CardDetailsEndpoints
return EndpointResults.BadRequest(result.Error); return EndpointResults.BadRequest(result.Error);
} }
return result.Card is null return await ReadCardAsync(context, cardId, ct);
? EndpointResults.NotFound(CardNotFoundDetail)
: await ReadCardAsync(context, cardId, ct);
} }
// DELETE /api/cards/{cardId}/reminder: снять напоминание. Ответ — карточка. // DELETE /api/cards/{cardId}/reminder: снять напоминание. Ответ — карточка.
@@ -374,9 +365,8 @@ public static class CardDetailsEndpoints
} }
CardsService service = context.RequestServices.GetRequiredService<CardsService>(); CardsService service = context.RequestServices.GetRequiredService<CardsService>();
return await service.ClearReminderAsync(cardId, ct) await service.ClearReminderAsync(cardId, ct);
? await ReadCardAsync(context, cardId, ct) return await ReadCardAsync(context, cardId, ct);
: EndpointResults.NotFound(CardNotFoundDetail);
} }
// POST /api/cards/{cardId}/reminder/snooze: «напомнить позже» (now + 24 ч). Ответ — карточка. // POST /api/cards/{cardId}/reminder/snooze: «напомнить позже» (now + 24 ч). Ответ — карточка.
@@ -391,9 +381,8 @@ public static class CardDetailsEndpoints
} }
CardsService service = context.RequestServices.GetRequiredService<CardsService>(); CardsService service = context.RequestServices.GetRequiredService<CardsService>();
return await service.SnoozeReminderAsync(cardId, ct) await service.SnoozeReminderAsync(cardId, ct);
? await ReadCardAsync(context, cardId, ct) return await ReadCardAsync(context, cardId, ct);
: EndpointResults.NotFound(CardNotFoundDetail);
} }
// Читает карточку через единый сервис и возвращает её как ответ (404 — карточки нет). // Читает карточку через единый сервис и возвращает её как ответ (404 — карточки нет).
@@ -412,11 +401,7 @@ public static class CardDetailsEndpoints
CancellationToken ct) CancellationToken ct)
{ {
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.GetCardAsync(cardId, ct); CardDto card = await cardsService.GetCardAsync(cardId, ct);
if (card is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
SourceContent content = await cardsService.ResolveSourceAsync(card, ct); SourceContent content = await cardsService.ResolveSourceAsync(card, ct);
return Results.Ok(content); return Results.Ok(content);
@@ -428,10 +413,8 @@ public static class CardDetailsEndpoints
CancellationToken ct) CancellationToken ct)
{ {
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.GetCardAsync(cardId, ct); CardDto card = await cardsService.GetCardAsync(cardId, ct);
return card is null return Results.Ok(card);
? EndpointResults.NotFound(CardNotFoundDetail)
: Results.Ok(card);
} }
// Имя файла для Content-Disposition без кавычек «"». // Имя файла для Content-Disposition без кавычек «"».
+5 -18
View File
@@ -128,10 +128,8 @@ public static class CardsEndpoints
} }
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.GetCardAsync(cardId, ct); CardDto card = await cardsService.GetCardAsync(cardId, ct);
return card is null return Results.Ok(card);
? EndpointResults.NotFound(CardNotFoundDetail)
: Results.Ok(card);
} }
private static async Task<IResult> MarkAllSeenAsync(HttpContext context, CancellationToken ct) private static async Task<IResult> MarkAllSeenAsync(HttpContext context, CancellationToken ct)
@@ -184,18 +182,11 @@ public static class CardsEndpoints
return EndpointResults.BadRequest(outcome.Error); return EndpointResults.BadRequest(outcome.Error);
} }
if (!outcome.Exists)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, new { cardId, to = body.To }, ct); await AuditAppender.AppendTenantAsync(context, AuditEvents.CardMoved, new { cardId, to = body.To }, ct);
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? unified = await cardsService.GetCardAsync(cardId, ct); CardDto unified = await cardsService.GetCardAsync(cardId, ct);
return unified is null return Results.Ok(unified);
? EndpointResults.NotFound(CardNotFoundDetail)
: Results.Ok(unified);
} }
private static async Task<IResult> TrashAsync( private static async Task<IResult> TrashAsync(
@@ -353,11 +344,7 @@ public static class CardsEndpoints
} }
CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>(); CardsService cardsService = context.RequestServices.GetRequiredService<CardsService>();
CardDto? card = await cardsService.GetCardAsync(cardId, ct); CardDto card = await cardsService.GetCardAsync(cardId, ct);
if (card is null)
{
return EndpointResults.NotFound(CardNotFoundDetail);
}
CardReclassifier reclassifier = context.RequestServices.GetRequiredService<CardReclassifier>(); CardReclassifier reclassifier = context.RequestServices.GetRequiredService<CardReclassifier>();
ReclassifyResultDto result = await reclassifier.ReclassifyCardAsync(card, ct); ReclassifyResultDto result = await reclassifier.ReclassifyCardAsync(card, ct);
@@ -22,8 +22,6 @@ public sealed class CardMover(CardsService cardsService) : ICardMover
CardResultDto result = CardsDefaultContainers.Contains(toContainerId) CardResultDto result = CardsDefaultContainers.Contains(toContainerId)
? await cardsService.MoveStageCardAsync(cardId, toContainerId, ct) ? await cardsService.MoveStageCardAsync(cardId, toContainerId, ct)
: await cardsService.MoveDashboardCardAsync(cardId, toContainerId, ct); : await cardsService.MoveDashboardCardAsync(cardId, toContainerId, ct);
return result.Error is not null return new CardMoveResultDto(result.Error);
? new CardMoveResultDto(result.Error, Exists: true)
: new CardMoveResultDto(null, Exists: result.Card is not null);
} }
} }
@@ -1,5 +1,6 @@
using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models; using Deal.Modules.Cards.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Cards.Application.Abstractions; namespace Deal.Modules.Cards.Application.Abstractions;
@@ -14,7 +15,8 @@ public interface ICardMover
/// <param name="cardId">Id карточки.</param> /// <param name="cardId">Id карточки.</param>
/// <param name="toContainerId">Id контейнера назначения (стадия «Выбранных» либо дашборд-контейнер).</param> /// <param name="toContainerId">Id контейнера назначения (стадия «Выбранных» либо дашборд-контейнер).</param>
/// <param name="ctx">Контекст перехода (инициатор, причина, обучение).</param> /// <param name="ctx">Контекст перехода (инициатор, причина, обучение).</param>
/// <returns>Результат: Error (400-текст отказа) | Exists=false (карточки нет, 404) | успех (Exists=true).</returns> /// <returns>Результат: Error (400-текст отказа) либо null (успех).</returns>
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
public Task<CardMoveResultDto> MoveAsync( public Task<CardMoveResultDto> MoveAsync(
string cardId, string cardId,
string toContainerId, string toContainerId,
@@ -5,6 +5,5 @@ namespace Deal.Modules.Cards.Application.Dtos;
/// <summary> /// <summary>
/// Результат перехода карточки единым механизмом <see cref="ICardMover"/>. /// Результат перехода карточки единым механизмом <see cref="ICardMover"/>.
/// </summary> /// </summary>
/// <param name="Error">Текст 400-ошибки либо null.</param> /// <param name="Error">Текст 400-ошибки либо null (успех).</param>
/// <param name="Exists">True — карточка найдена и переход выполнен (либо перенос был no-op).</param> public sealed record CardMoveResultDto(string? Error);
public sealed record CardMoveResultDto(string? Error, bool Exists);
@@ -27,9 +27,16 @@ public sealed partial class CardsService
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>Карточка или null — строки нет (эндпоинт отвечает 404 «Карточка не найдена»).</returns> /// <returns>Карточка или null — строки нет (эндпоинт отвечает 404 «Карточка не найдена»).</returns>
public Task<CardDto?> GetCardAsync(string cardId, CancellationToken ct) /// <summary>
/// Одна карточка по id.
/// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>Карточка.</returns>
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardDto> GetCardAsync(string cardId, CancellationToken ct)
{ {
return _store.GetCardAsync(cardId, ct); return await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId);
} }
@@ -57,7 +64,7 @@ public sealed partial class CardsService
CardDto? card = await _store.GetCardAsync(cardId, ct); CardDto? card = await _store.GetCardAsync(cardId, ct);
if (card is null) if (card is null)
{ {
return new CardResultDto(null, null); throw new NotFoundException(CardEntityName, cardId);
} }
if (card.Col == CardIds.Archive || card.Col == CardIds.Trash) if (card.Col == CardIds.Archive || card.Col == CardIds.Trash)
@@ -81,7 +88,10 @@ public sealed partial class CardsService
await _mlClient.PushAsync(text, toCol, PushWeightUser, ct); await _mlClient.PushAsync(text, toCol, PushWeightUser, ct);
} }
return new CardResultDto(null, await _store.GetCardAsync(cardId, ct)); return new CardResultDto(
null,
await _store.GetCardAsync(cardId, ct)
?? throw new NotFoundException(CardEntityName, cardId));
} }
/// <summary> /// <summary>
@@ -1,5 +1,6 @@
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Settings.Application.Models; using Deal.Modules.Settings.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Kanban.Application.Services; namespace Deal.Modules.Kanban.Application.Services;
@@ -22,17 +23,15 @@ public sealed partial class CardsService
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="atMs">Время напоминания, epoch-ms.</param> /// <param name="atMs">Время напоминания, epoch-ms.</param>
/// <returns>Результат: Error <see cref="RemindersDisabledDetail"/> (400) | Card=null без Error (404) | Card — карточка с напоминанием.</returns> /// <returns>Результат: Error <see cref="RemindersDisabledDetail"/> (400) | Card — карточка с напоминанием.</returns>
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardResultDto> SetReminderAsync( public async Task<CardResultDto> SetReminderAsync(
string cardId, string cardId,
long atMs, long atMs,
CancellationToken ct) CancellationToken ct)
{ {
CardDto? card = await _store.GetCardAsync(cardId, ct); CardDto card = await _store.GetCardAsync(cardId, ct)
if (card is null) ?? throw new NotFoundException(CardEntityName, cardId);
{
return new CardResultDto(null, null);
}
if (!await ReadRemindersEnabledAsync(ct)) if (!await ReadRemindersEnabledAsync(ct))
{ {
@@ -41,7 +40,7 @@ public sealed partial class CardsService
await _store.SetReminderAsync(cardId, atMs, ct); await _store.SetReminderAsync(cardId, atMs, ct);
CardDto saved = await _store.GetCardAsync(cardId, ct) CardDto saved = await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после установки напоминания: " + cardId); ?? throw new NotFoundException(CardEntityName, cardId);
return new CardResultDto(null, saved); return new CardResultDto(null, saved);
} }
@@ -49,35 +48,27 @@ public sealed partial class CardsService
/// Снимает напоминание карточки. /// Снимает напоминание карточки.
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>True — карточка есть и напоминание снято; false — карточки нет (404).</returns> /// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<bool> ClearReminderAsync(string cardId, CancellationToken ct) public async Task ClearReminderAsync(string cardId, CancellationToken ct)
{ {
CardDto? card = await _store.GetCardAsync(cardId, ct); _ = await _store.GetCardAsync(cardId, ct)
if (card is null) ?? throw new NotFoundException(CardEntityName, cardId);
{
return false;
}
await _store.ClearReminderAsync(cardId, ct); await _store.ClearReminderAsync(cardId, ct);
return true;
} }
/// <summary> /// <summary>
/// «Напомнить позже» /// «Напомнить позже»
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <returns>True — карточка есть и напоминание отложено; false — карточки нет (404).</returns> /// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<bool> SnoozeReminderAsync(string cardId, CancellationToken ct) public async Task SnoozeReminderAsync(string cardId, CancellationToken ct)
{ {
CardDto? card = await _store.GetCardAsync(cardId, ct); _ = await _store.GetCardAsync(cardId, ct)
if (card is null) ?? throw new NotFoundException(CardEntityName, cardId);
{
return false;
}
long snoozedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + ReminderSnoozeMs; long snoozedAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() + ReminderSnoozeMs;
await _store.SetReminderAsync(cardId, snoozedAtMs, ct); await _store.SetReminderAsync(cardId, snoozedAtMs, ct);
return true;
} }
/// <summary> /// <summary>
@@ -12,6 +12,9 @@ namespace Deal.Modules.Kanban.Application.Services;
/// </summary> /// </summary>
public sealed partial class CardsService public sealed partial class CardsService
{ {
// Имя сущности для текста ошибки «не найдено».
private const string CardLinkEntityName = "Ссылка карточки";
/// <summary> /// <summary>
/// 400 перенос по стадии /// 400 перенос по стадии
/// </summary> /// </summary>
@@ -159,18 +162,16 @@ public sealed partial class CardsService
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="name">Название ссылки; пустое после Trim → name = url.</param> /// <param name="name">Название ссылки; пустое после Trim → name = url.</param>
/// <param name="url">URL ссылки (без схемы — добавится https://).</param> /// <param name="url">URL ссылки (без схемы — добавится https://).</param>
/// <returns>Результат: Error (400 «Пустая ссылка») | Card=null без Error (404) | Card — карточка со ссылкой.</returns> /// <returns>Результат: Error (400 «Пустая ссылка») | Card — карточка со ссылкой.</returns>
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardResultDto> AddLinkAsync( public async Task<CardResultDto> AddLinkAsync(
string cardId, string cardId,
string name, string name,
string url, string url,
CancellationToken ct) CancellationToken ct)
{ {
CardDto? card = await _store.GetCardAsync(cardId, ct); CardDto card = await _store.GetCardAsync(cardId, ct)
if (card is null) ?? throw new NotFoundException(CardEntityName, cardId);
{
return new CardResultDto(null, null);
}
string normalizedUrl = (url ?? string.Empty).Trim(); string normalizedUrl = (url ?? string.Empty).Trim();
if (normalizedUrl.Length == 0) if (normalizedUrl.Length == 0)
@@ -191,11 +192,11 @@ public sealed partial class CardsService
normalizedUrl); normalizedUrl);
if (!await _store.AddLinkAsync(cardId, link, ct)) if (!await _store.AddLinkAsync(cardId, link, ct))
{ {
return new CardResultDto(null, null); throw new NotFoundException(CardEntityName, cardId);
} }
CardDto saved = await _store.GetCardAsync(cardId, ct) CardDto saved = await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после добавления ссылки: " + cardId); ?? throw new NotFoundException(CardEntityName, cardId);
return new CardResultDto(null, saved); return new CardResultDto(null, saved);
} }
@@ -204,7 +205,8 @@ public sealed partial class CardsService
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="linkId">Id удаляемой ссылки (<c>pl_...</c>).</param> /// <param name="linkId">Id удаляемой ссылки (<c>pl_...</c>).</param>
/// <returns>Результат: Card=null без Error (404) | Card — карточка без ссылки.</returns> /// <returns>Карточка без ссылки.</returns>
/// <exception cref="NotFoundException">Карточка или ссылка не найдены.</exception>
public async Task<CardResultDto> RemoveLinkAsync( public async Task<CardResultDto> RemoveLinkAsync(
string cardId, string cardId,
string linkId, string linkId,
@@ -212,11 +214,11 @@ public sealed partial class CardsService
{ {
if (!await _store.RemoveLinkAsync(cardId, linkId, ct)) if (!await _store.RemoveLinkAsync(cardId, linkId, ct))
{ {
return new CardResultDto(null, null); throw new NotFoundException(CardLinkEntityName, linkId);
} }
CardDto saved = await _store.GetCardAsync(cardId, ct) CardDto saved = await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после удаления ссылки: " + cardId); ?? throw new NotFoundException(CardEntityName, cardId);
return new CardResultDto(null, saved); return new CardResultDto(null, saved);
} }
@@ -225,7 +227,8 @@ public sealed partial class CardsService
/// </summary> /// </summary>
/// <param name="cardId">Id карточки (<c>c_...</c>).</param> /// <param name="cardId">Id карточки (<c>c_...</c>).</param>
/// <param name="containerId">Новый контейнер-стадия — id каталога <see cref="CardsDefaultContainers"/>.</param> /// <param name="containerId">Новый контейнер-стадия — id каталога <see cref="CardsDefaultContainers"/>.</param>
/// <returns>Результат: Error «Неизвестная стадия» (400) | Card=null без Error (карточки нет, 404) | Card — карточка после переноса.</returns> /// <returns>Результат: Error «Неизвестная стадия» (400) | Card — карточка после переноса.</returns>
/// <exception cref="NotFoundException">Карточка не найдена.</exception>
public async Task<CardResultDto> MoveStageCardAsync( public async Task<CardResultDto> MoveStageCardAsync(
string cardId, string cardId,
string containerId, string containerId,
@@ -241,11 +244,11 @@ public sealed partial class CardsService
bool moved = await _store.MoveCardStageAsync(cardId, containerId, entry, nowMs, ct); bool moved = await _store.MoveCardStageAsync(cardId, containerId, entry, nowMs, ct);
if (!moved) if (!moved)
{ {
return new CardResultDto(null, null); throw new NotFoundException(CardEntityName, cardId);
} }
CardDto card = await _store.GetCardAsync(cardId, ct) CardDto card = await _store.GetCardAsync(cardId, ct)
?? throw new InvalidOperationException("Карточка не прочиталась после move: " + cardId); ?? throw new NotFoundException(CardEntityName, cardId);
return new CardResultDto(null, card); return new CardResultDto(null, card);
} }
@@ -53,13 +53,12 @@ public sealed class CardsServiceTests
} }
[Fact] [Fact]
public async Task GetCard_Missing_ReturnsNull() public async Task GetCard_Missing_ThrowsNotFound()
{ {
(CardsService service, _, _, _) = Create(); (CardsService service, _, _, _) = Create();
CardDto? card = await service.GetCardAsync("l_missing", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.GetCardAsync("l_missing", CancellationToken.None));
Assert.Null(card);
} }
@@ -146,15 +145,14 @@ public sealed class CardsServiceTests
} }
[Fact] [Fact]
public async Task Move_CardMissing_ReturnsNullLead() public async Task Move_CardMissing_ThrowsNotFound()
{ {
(CardsService service, TestKanjStore 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); await Assert.ThrowsAsync<NotFoundException>(
() => service.MoveDashboardCardAsync("l_ghost", "b_py", CancellationToken.None));
Assert.Null(result.Error);
Assert.Null(result.Card); // эндпоинт отвечает 404 «Карточка не найдена»
Assert.Empty(store.Moves); Assert.Empty(store.Moves);
Assert.Empty(ml.Pushed); Assert.Empty(ml.Pushed);
} }
@@ -3,10 +3,11 @@ using Deal.Modules.Cards.Application.Dtos;
using Deal.Modules.Cards.Application.Models; using Deal.Modules.Cards.Application.Models;
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.Tests.Unit.Support;
using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Kanban.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Infrastructure; namespace Deal.Tests.Unit.Infrastructure;
@@ -32,7 +33,6 @@ public sealed class CardMoverTests
CardMoveResultDto result = await mover.MoveAsync("c_1", CardsDefaultContainers.Planned, UserMove, CancellationToken.None); CardMoveResultDto result = await mover.MoveAsync("c_1", CardsDefaultContainers.Planned, UserMove, CancellationToken.None);
Assert.Null(result.Error); Assert.Null(result.Error);
Assert.True(result.Exists);
CardDto card = Assert.Single(store.CardDtos); CardDto card = Assert.Single(store.CardDtos);
Assert.Equal(CardsDefaultContainers.Planned, card.Col); Assert.Equal(CardsDefaultContainers.Planned, card.Col);
Assert.Null(card.Reminder); // move по стадии сбрасывает напоминание Assert.Null(card.Reminder); // move по стадии сбрасывает напоминание
@@ -50,7 +50,6 @@ public sealed class CardMoverTests
CardMoveResultDto result = await mover.MoveAsync("c_1", "b_py", UserMove, CancellationToken.None); CardMoveResultDto result = await mover.MoveAsync("c_1", "b_py", UserMove, CancellationToken.None);
Assert.Null(result.Error); Assert.Null(result.Error);
Assert.True(result.Exists);
Assert.Equal("b_py", Assert.Single(store.CardDtos).Col); Assert.Equal("b_py", Assert.Single(store.CardDtos).Col);
Assert.Single(store.Moves); // журнал CardMoves дашборд-переноса Assert.Single(store.Moves); // журнал CardMoves дашборд-переноса
} }
@@ -64,18 +63,15 @@ public sealed class CardMoverTests
CardMoveResultDto result = await mover.MoveAsync("c_1", "b_ghost", UserMove, CancellationToken.None); CardMoveResultDto result = await mover.MoveAsync("c_1", "b_ghost", UserMove, CancellationToken.None);
Assert.Equal(CardsService.MoveTargetInvalidDetail, result.Error); Assert.Equal(CardsService.MoveTargetInvalidDetail, result.Error);
Assert.True(result.Exists); // ошибка важнее признака наличия
} }
[Fact] [Fact]
public async Task Move_ToStage_CardMissing_ReportsNotFound() public async Task Move_ToStage_CardMissing_ThrowsNotFound()
{ {
(CardMover mover, _) = Create(); (CardMover mover, _) = Create();
CardMoveResultDto result = await mover.MoveAsync("c_ghost", CardsDefaultContainers.Planned, UserMove, CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => mover.MoveAsync("c_ghost", CardsDefaultContainers.Planned, UserMove, CancellationToken.None));
Assert.Null(result.Error);
Assert.False(result.Exists);
} }
private static (CardMover Mover, TestKanjStore Store) Create() private static (CardMover Mover, TestKanjStore Store) Create()
@@ -1,9 +1,10 @@
using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Kanban.Application.Models;
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.SharedKernel.Errors;
using Deal.Tests.Unit.Contracts; using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Settings; using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Kanban; namespace Deal.Tests.Unit.Modules.Kanban;
@@ -47,14 +48,12 @@ public sealed class CardsServiceRemindersTests
} }
[Fact] [Fact]
public async Task Set_MissingCard_Returns404EvenWhenDisabled() public async Task Set_MissingCard_ThrowsNotFoundEvenWhenDisabled()
{ {
(CardsService service, _, _) = Create(remindersEnabled: false); (CardsService service, _, _) = Create(remindersEnabled: false);
CardResultDto result = await service.SetReminderAsync("c_missing", NowMs(), CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.SetReminderAsync("c_missing", NowMs(), CancellationToken.None));
Assert.Null(result.Error);
Assert.Null(result.Card); // эндпоинт отвечает 404 «Карточка не найдена»
} }
[Fact] [Fact]
@@ -73,25 +72,23 @@ public sealed class CardsServiceRemindersTests
[Fact] [Fact]
public async Task Clear_WithReminder_ClearsItAndReturnsTrue() public async Task Clear_WithReminder_ClearsIt()
{ {
(CardsService service, TestKanjStore 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); await service.ClearReminderAsync("c_1", CancellationToken.None);
Assert.True(cleared);
Assert.Null(Assert.Single(store.CardDtos).Reminder); // reminder_at=NULL, fired сброшен Assert.Null(Assert.Single(store.CardDtos).Reminder); // reminder_at=NULL, fired сброшен
} }
[Fact] [Fact]
public async Task Clear_MissingCard_ReturnsFalse() public async Task Clear_MissingCard_ThrowsNotFound()
{ {
(CardsService service, _, _) = Create(); (CardsService service, _, _) = Create();
bool cleared = await service.ClearReminderAsync("c_missing", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.ClearReminderAsync("c_missing", CancellationToken.None));
Assert.False(cleared); // эндпоинт отвечает 404 «Карточка не найдена»
} }
[Fact] [Fact]
@@ -100,9 +97,8 @@ public sealed class CardsServiceRemindersTests
(CardsService service, TestKanjStore 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); await service.ClearReminderAsync("c_1", CancellationToken.None);
Assert.True(cleared);
Assert.Null(Assert.Single(store.CardDtos).Reminder); Assert.Null(Assert.Single(store.CardDtos).Reminder);
} }
@@ -114,22 +110,20 @@ public sealed class CardsServiceRemindersTests
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();
bool snoozed = await service.SnoozeReminderAsync("c_1", CancellationToken.None); await service.SnoozeReminderAsync("c_1", CancellationToken.None);
Assert.True(snoozed);
long afterMs = NowMs(); long afterMs = NowMs();
CardReminderDto reminder = Assert.Single(store.CardDtos).Reminder!; CardReminderDto reminder = Assert.Single(store.CardDtos).Reminder!;
Assert.InRange(reminder.At, beforeMs + DayMs, afterMs + DayMs); // now + 24 ч Assert.InRange(reminder.At, beforeMs + DayMs, afterMs + DayMs); // now + 24 ч
} }
[Fact] [Fact]
public async Task Snooze_MissingCard_ReturnsFalse() public async Task Snooze_MissingCard_ThrowsNotFound()
{ {
(CardsService service, _, _) = Create(); (CardsService service, _, _) = Create();
bool snoozed = await service.SnoozeReminderAsync("c_missing", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.SnoozeReminderAsync("c_missing", CancellationToken.None));
Assert.False(snoozed); // эндпоинт отвечает 404 «Карточка не найдена»
} }
[Fact] [Fact]
@@ -138,9 +132,8 @@ public sealed class CardsServiceRemindersTests
(CardsService service, TestKanjStore 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); await service.SnoozeReminderAsync("c_1", CancellationToken.None);
Assert.True(snoozed);
Assert.NotNull(Assert.Single(store.CardDtos).Reminder); Assert.NotNull(Assert.Single(store.CardDtos).Reminder);
} }
@@ -40,13 +40,12 @@ public sealed class CardsServiceSelectedTests
} }
[Fact] [Fact]
public async Task Get_Missing_ReturnsNull() public async Task Get_Missing_ThrowsNotFound()
{ {
(CardsService service, _, _, _) = Create(); (CardsService service, _, _, _) = Create();
CardDto? card = await service.GetCardAsync("c_missing", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.GetCardAsync("c_missing", CancellationToken.None));
Assert.Null(card);
} }
@@ -319,14 +318,12 @@ public sealed class CardsServiceSelectedTests
} }
[Fact] [Fact]
public async Task Move_CardMissing_ReturnsNullCard() public async Task Move_CardMissing_ThrowsNotFound()
{ {
(CardsService service, _, _, _) = Create(); (CardsService service, _, _, _) = Create();
CardResultDto result = await service.MoveStageCardAsync("c_missing", "work", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.MoveStageCardAsync("c_missing", "work", CancellationToken.None));
Assert.Null(result.Error);
Assert.Null(result.Card); // 404 «Карточка не найдена» — текст у эндпоинта
} }
@@ -465,14 +462,13 @@ public sealed class CardsServiceSelectedTests
} }
[Fact] [Fact]
public async Task AddLink_CardMissing_ReturnsNullCardBeforeUrlValidation() public async Task AddLink_CardMissing_ThrowsBeforeUrlValidation()
{ {
(CardsService service, TestKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
CardResultDto result = await service.AddLinkAsync("c_missing", string.Empty, "example.com", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.AddLinkAsync("c_missing", string.Empty, "example.com", CancellationToken.None));
Assert.Null(result.Error);
Assert.Null(result.Card); // 404-семантика: карточки нет раньше валидации url
Assert.Empty(store.CardDtos); Assert.Empty(store.CardDtos);
} }
@@ -514,14 +510,13 @@ public sealed class CardsServiceSelectedTests
} }
[Fact] [Fact]
public async Task RemoveLink_CardMissing_ReturnsNullCard() public async Task RemoveLink_CardMissing_ThrowsNotFound()
{ {
(CardsService service, TestKanjStore store, _, _) = Create(); (CardsService service, TestKanjStore store, _, _) = Create();
CardResultDto result = await service.RemoveLinkAsync("c_missing", "pl_1", CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.RemoveLinkAsync("c_missing", "pl_1", CancellationToken.None));
Assert.Null(result.Error);
Assert.Null(result.Card); // эндпоинт отвечает 404 «Карточка не найдена»
Assert.Empty(store.CardDtos); Assert.Empty(store.CardDtos);
} }