Перевести «не найдено» кандидатов Discovery на исключения

DiscoveryCandidatesService (get/set/status/mark_joined/mark_rejected/add при отсутствии задачи) бросает NotFoundException; skip-ветки add оставлены null. Эндпоинты без локальных 404-проверок.
This commit is contained in:
2026-09-13 18:27:18 +03:00
parent d2d6b81aa0
commit f93fb0fdd3
3 changed files with 66 additions and 76 deletions
@@ -55,9 +55,6 @@ public static class DiscoveryEndpoints
// Путь лога задачи (GET). // Путь лога задачи (GET).
private const string TaskLogPath = "/tasks/{task_id}/log"; private const string TaskLogPath = "/tasks/{task_id}/log";
// 404: кандидат не найден.
private const string CandidateNotFoundDetail = "Кандидат не найден";
private const string AlreadyJoinedDetail = "Уже вступили в этот источник"; private const string AlreadyJoinedDetail = "Уже вступили в этот источник";
private const string JoinedRejectDetail = "Уже вступили — удалите источник из каналов"; private const string JoinedRejectDetail = "Уже вступили — удалите источник из каналов";
@@ -281,11 +278,7 @@ public static class DiscoveryEndpoints
} }
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>(); DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct); DiscoveryCandidateDto row = await candidates.GetAsync(dialog_id, ct);
if (row is null)
{
return EndpointResults.NotFound(CandidateNotFoundDetail);
}
if (row.Status == DiscoveryCandidateStatuses.Joined) if (row.Status == DiscoveryCandidateStatuses.Joined)
{ {
@@ -314,8 +307,8 @@ public static class DiscoveryEndpoints
try try
{ {
DiscoveryCandidateDto? joined = await candidates.MarkJoinedAsync(dialog_id, auto: false, ct); DiscoveryCandidateDto joined = await candidates.MarkJoinedAsync(dialog_id, auto: false, ct);
return joined is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(joined); return Results.Ok(joined);
} }
catch (DiscoveryValidationException exception) catch (DiscoveryValidationException exception)
{ {
@@ -334,11 +327,7 @@ public static class DiscoveryEndpoints
} }
DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>(); DiscoveryCandidatesService candidates = context.RequestServices.GetRequiredService<DiscoveryCandidatesService>();
DiscoveryCandidateDto? row = await candidates.GetAsync(dialog_id, ct); DiscoveryCandidateDto row = await candidates.GetAsync(dialog_id, ct);
if (row is null)
{
return EndpointResults.NotFound(CandidateNotFoundDetail);
}
if (row.Status == DiscoveryCandidateStatuses.Joined) if (row.Status == DiscoveryCandidateStatuses.Joined)
{ {
@@ -347,8 +336,8 @@ public static class DiscoveryEndpoints
try try
{ {
DiscoveryCandidateDto? rejected = await candidates.MarkRejectedAsync(dialog_id, ManualRejectReason, ct); DiscoveryCandidateDto rejected = await candidates.MarkRejectedAsync(dialog_id, ManualRejectReason, ct);
return rejected is null ? EndpointResults.NotFound(CandidateNotFoundDetail) : Results.Ok(rejected); return Results.Ok(rejected);
} }
catch (DiscoveryValidationException exception) catch (DiscoveryValidationException exception)
{ {
@@ -2,6 +2,7 @@ using Deal.Contracts.Integrations.Models;
using Deal.Modules.Discovery.Application.Abstractions; using Deal.Modules.Discovery.Application.Abstractions;
using Deal.Modules.Discovery.Application.Exceptions; using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Models;
using Deal.SharedKernel.Errors;
namespace Deal.Modules.Discovery.Application.Services; namespace Deal.Modules.Discovery.Application.Services;
@@ -13,6 +14,10 @@ public sealed class DiscoveryCandidatesService(
DiscoveryLogService log, DiscoveryLogService log,
DiscoveryBlacklistService blacklist) DiscoveryBlacklistService blacklist)
{ {
private const string CandidateEntityName = "Кандидат";
private const string TaskEntityName = "Задача поиска";
/// <summary> /// <summary>
/// 400 mark_rejected /// 400 mark_rejected
/// </summary> /// </summary>
@@ -53,10 +58,12 @@ public sealed class DiscoveryCandidatesService(
/// Кандидат по dialog_id. /// Кандидат по dialog_id.
/// </summary> /// </summary>
/// <param name="dialogId">Подписанный id источника.</param> /// <param name="dialogId">Подписанный id источника.</param>
/// <returns>Кандидат или null (404 «Кандидат не найден»).</returns> /// <returns>Кандидат.</returns>
public Task<DiscoveryCandidateDto?> GetAsync(string dialogId, CancellationToken ct) /// <exception cref="NotFoundException">Кандидат не найден.</exception>
public async Task<DiscoveryCandidateDto> GetAsync(string dialogId, CancellationToken ct)
{ {
return store.GetCandidateAsync(dialogId, ct); return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
?? throw new NotFoundException(CandidateEntityName, dialogId);
} }
/// <summary> /// <summary>
@@ -78,11 +85,8 @@ public sealed class DiscoveryCandidatesService(
string hue, string hue,
CancellationToken ct) CancellationToken ct)
{ {
DiscoveryTaskDto? task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); DiscoveryTaskDto task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
if (task is null) ?? throw new NotFoundException(TaskEntityName, taskId);
{
return null;
}
if (await store.IsDialogMonitoredAsync(dialogId, ct).ConfigureAwait(false)) if (await store.IsDialogMonitoredAsync(dialogId, ct).ConfigureAwait(false))
{ {
@@ -125,7 +129,8 @@ public sealed class DiscoveryCandidatesService(
}; };
await store.CreateCandidateAsync(row, ct).ConfigureAwait(false); await store.CreateCandidateAsync(row, ct).ConfigureAwait(false);
await store.BumpTaskCounterAsync(taskId, DiscoveryCounterField.Found, 1, ct).ConfigureAwait(false); await store.BumpTaskCounterAsync(taskId, DiscoveryCounterField.Found, 1, ct).ConfigureAwait(false);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
?? throw new NotFoundException(CandidateEntityName, dialogId);
} }
/// <summary> /// <summary>
@@ -134,18 +139,21 @@ public sealed class DiscoveryCandidatesService(
/// <param name="taskId">Id задачи.</param> /// <param name="taskId">Id задачи.</param>
/// <param name="dialogId">Подписанный id источника.</param> /// <param name="dialogId">Подписанный id источника.</param>
/// <param name="patch">Изменяемые поля (null — не менять).</param> /// <param name="patch">Изменяемые поля (null — не менять).</param>
/// <returns>Обновлённый кандидат либо null — задачи/кандидата нет (или кандидат другой задачи).</returns> /// <returns>Обновлённый кандидат.</returns>
public async Task<DiscoveryCandidateDto?> SetAsync( /// <exception cref="NotFoundException">Задача или кандидат не найдены.</exception>
public async Task<DiscoveryCandidateDto> SetAsync(
string taskId, string taskId,
string dialogId, string dialogId,
DiscoveryCandidatePatch patch, DiscoveryCandidatePatch patch,
CancellationToken ct) CancellationToken ct)
{ {
DiscoveryTaskDto? task = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false); _ = await store.GetTaskAsync(taskId, ct).ConfigureAwait(false)
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); ?? throw new NotFoundException(TaskEntityName, taskId);
if (task is null || current is null || current.TaskId != taskId) DiscoveryCandidateDto current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
?? throw new NotFoundException(CandidateEntityName, dialogId);
if (current.TaskId != taskId)
{ {
return null; throw new NotFoundException(CandidateEntityName, dialogId);
} }
DiscoveryCandidatePatch normalized = NormalizePatch(patch, current); DiscoveryCandidatePatch normalized = NormalizePatch(patch, current);
@@ -159,7 +167,8 @@ public sealed class DiscoveryCandidatesService(
} }
await store.PatchCandidateAsync(dialogId, normalized, ct).ConfigureAwait(false); await store.PatchCandidateAsync(dialogId, normalized, ct).ConfigureAwait(false);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
?? throw new NotFoundException(CandidateEntityName, dialogId);
} }
/// <summary> /// <summary>
@@ -167,9 +176,10 @@ public sealed class DiscoveryCandidatesService(
/// </summary> /// </summary>
/// <param name="dialogId">Подписанный id источника.</param> /// <param name="dialogId">Подписанный id источника.</param>
/// <param name="status">Новый статус: new|review.</param> /// <param name="status">Новый статус: new|review.</param>
/// <returns>Обновлённый кандидат либо null (кандидата нет).</returns> /// <returns>Обновлённый кандидат.</returns>
/// <exception cref="NotFoundException">Кандидат не найден.</exception>
/// <exception cref="DiscoveryValidationException">Статус не new/review.</exception> /// <exception cref="DiscoveryValidationException">Статус не new/review.</exception>
public async Task<DiscoveryCandidateDto?> SetStatusAsync( public async Task<DiscoveryCandidateDto> SetStatusAsync(
string dialogId, string dialogId,
string status, string status,
CancellationToken ct) CancellationToken ct)
@@ -179,11 +189,8 @@ public sealed class DiscoveryCandidatesService(
throw new DiscoveryValidationException(string.Format(TransitionNotAllowedFormat, status)); throw new DiscoveryValidationException(string.Format(TransitionNotAllowedFormat, status));
} }
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); DiscoveryCandidateDto current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
if (current is null) ?? throw new NotFoundException(CandidateEntityName, dialogId);
{
return null;
}
await store.SetCandidateStatusAsync(dialogId, status, ct).ConfigureAwait(false); await store.SetCandidateStatusAsync(dialogId, status, ct).ConfigureAwait(false);
if (status == DiscoveryCandidateStatuses.Review) if (status == DiscoveryCandidateStatuses.Review)
@@ -195,7 +202,8 @@ public sealed class DiscoveryCandidatesService(
ct).ConfigureAwait(false); ct).ConfigureAwait(false);
} }
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
?? throw new NotFoundException(CandidateEntityName, dialogId);
} }
/// <summary> /// <summary>
@@ -203,17 +211,15 @@ public sealed class DiscoveryCandidatesService(
/// </summary> /// </summary>
/// <param name="dialogId">Подписанный id источника.</param> /// <param name="dialogId">Подписанный id источника.</param>
/// <param name="auto">True — авто-вступление воркера; false — ручное.</param> /// <param name="auto">True — авто-вступление воркера; false — ручное.</param>
/// <returns>Кандидат в joined либо null (кандидата нет). Повторный вызов для joined — идемпотентен.</returns> /// <returns>Кандидат в joined. Повторный вызов для joined — идемпотентен.</returns>
public async Task<DiscoveryCandidateDto?> MarkJoinedAsync( /// <exception cref="NotFoundException">Кандидат не найден.</exception>
public async Task<DiscoveryCandidateDto> MarkJoinedAsync(
string dialogId, string dialogId,
bool auto, bool auto,
CancellationToken ct) CancellationToken ct)
{ {
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); DiscoveryCandidateDto current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
if (current is null) ?? throw new NotFoundException(CandidateEntityName, dialogId);
{
return null;
}
if (current.Status == DiscoveryCandidateStatuses.Joined) if (current.Status == DiscoveryCandidateStatuses.Joined)
{ {
@@ -224,7 +230,8 @@ public sealed class DiscoveryCandidatesService(
await store.BumpTaskCounterAsync(current.TaskId, DiscoveryCounterField.Joined, 1, ct).ConfigureAwait(false); await store.BumpTaskCounterAsync(current.TaskId, DiscoveryCounterField.Joined, 1, ct).ConfigureAwait(false);
string logEvent = auto ? DiscoveryLogEvents.JoinAuto : DiscoveryLogEvents.JoinManual; string logEvent = auto ? DiscoveryLogEvents.JoinAuto : DiscoveryLogEvents.JoinManual;
await log.AddAsync(current.TaskId, logEvent, string.Format(JoinedLogFormat, dialogId), ct).ConfigureAwait(false); await log.AddAsync(current.TaskId, logEvent, string.Format(JoinedLogFormat, dialogId), ct).ConfigureAwait(false);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
?? throw new NotFoundException(CandidateEntityName, dialogId);
} }
/// <summary> /// <summary>
@@ -232,18 +239,16 @@ public sealed class DiscoveryCandidatesService(
/// </summary> /// </summary>
/// <param name="dialogId">Подписанный id источника.</param> /// <param name="dialogId">Подписанный id источника.</param>
/// <param name="reason">Причина отклонения (в лог/чёрный список; «отклонено вручную» — эндпоинт reject).</param> /// <param name="reason">Причина отклонения (в лог/чёрный список; «отклонено вручную» — эндпоинт reject).</param>
/// <returns>Кандидат в rejected либо null (кандидата нет). Повторный вызов для rejected — идемпотентен.</returns> /// <returns>Кандидат в rejected. Повторный вызов для rejected — идемпотентен.</returns>
/// <exception cref="NotFoundException">Кандидат не найден.</exception>
/// <exception cref="DiscoveryValidationException">Источник уже joined — отклонить нельзя.</exception> /// <exception cref="DiscoveryValidationException">Источник уже joined — отклонить нельзя.</exception>
public async Task<DiscoveryCandidateDto?> MarkRejectedAsync( public async Task<DiscoveryCandidateDto> MarkRejectedAsync(
string dialogId, string dialogId,
string reason, string reason,
CancellationToken ct) CancellationToken ct)
{ {
DiscoveryCandidateDto? current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); DiscoveryCandidateDto current = await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
if (current is null) ?? throw new NotFoundException(CandidateEntityName, dialogId);
{
return null;
}
if (current.Status == DiscoveryCandidateStatuses.Joined) if (current.Status == DiscoveryCandidateStatuses.Joined)
{ {
@@ -260,7 +265,8 @@ public sealed class DiscoveryCandidatesService(
string text = string.IsNullOrEmpty(reason) ? string.Format(RejectedLogFormat, dialogId) : reason; string text = string.IsNullOrEmpty(reason) ? string.Format(RejectedLogFormat, dialogId) : reason;
await log.AddAsync(current.TaskId, DiscoveryLogEvents.Reject, text, ct).ConfigureAwait(false); await log.AddAsync(current.TaskId, DiscoveryLogEvents.Reject, text, ct).ConfigureAwait(false);
await blacklist.AddAsync(dialogId, current.Name, reason ?? string.Empty, ct).ConfigureAwait(false); await blacklist.AddAsync(dialogId, current.Name, reason ?? string.Empty, ct).ConfigureAwait(false);
return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false); return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false)
?? throw new NotFoundException(CandidateEntityName, dialogId);
} }
/// <summary> /// <summary>
@@ -1,7 +1,8 @@
using Deal.Modules.Discovery.Application.Exceptions; using Deal.Modules.Discovery.Application.Exceptions;
using Deal.Modules.Discovery.Application.Models; using Deal.Modules.Discovery.Application.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Discovery.Application.Services; using Deal.Modules.Discovery.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Discovery; namespace Deal.Tests.Unit.Modules.Discovery;
@@ -114,14 +115,13 @@ public sealed class DiscoveryCandidatesServiceTests
} }
[Fact] [Fact]
public async Task Add_MissingTask_ReturnsNullWithoutLog() public async Task Add_MissingTask_ThrowsNotFoundWithoutLog()
{ {
(DiscoveryCandidatesService service, TestDiscoveryStore store) = Create(); (DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
DiscoveryCandidateDto? candidate = await service.AddAsync( await Assert.ThrowsAsync<NotFoundException>(() => service.AddAsync(
"dt_missing", "-1001", "Канал", "", DiscoveryCandidateKinds.Channel, "", CancellationToken.None); "dt_missing", "-1001", "Канал", "", DiscoveryCandidateKinds.Channel, "", CancellationToken.None));
Assert.Null(candidate);
Assert.Empty(store.Candidates); Assert.Empty(store.Candidates);
Assert.Empty(store.Log); Assert.Empty(store.Log);
} }
@@ -175,13 +175,12 @@ public sealed class DiscoveryCandidatesServiceTests
} }
[Fact] [Fact]
public async Task MarkJoined_MissingCandidate_ReturnsNull() public async Task MarkJoined_MissingCandidate_ThrowsNotFound()
{ {
(DiscoveryCandidatesService service, _) = Create(); (DiscoveryCandidatesService service, _) = Create();
DiscoveryCandidateDto? candidate = await service.MarkJoinedAsync("-1001", auto: true, CancellationToken.None); await Assert.ThrowsAsync<NotFoundException>(
() => service.MarkJoinedAsync("-1001", auto: true, CancellationToken.None));
Assert.Null(candidate);
} }
[Fact] [Fact]
@@ -238,14 +237,12 @@ public sealed class DiscoveryCandidatesServiceTests
} }
[Fact] [Fact]
public async Task MarkRejected_MissingCandidate_ReturnsNull() public async Task MarkRejected_MissingCandidate_ThrowsNotFound()
{ {
(DiscoveryCandidatesService service, _) = Create(); (DiscoveryCandidatesService service, _) = Create();
DiscoveryCandidateDto? candidate = await service.MarkRejectedAsync( await Assert.ThrowsAsync<NotFoundException>(
"-1001", "причина", CancellationToken.None); () => service.MarkRejectedAsync("-1001", "причина", CancellationToken.None));
Assert.Null(candidate);
} }
[Fact] [Fact]
@@ -315,17 +312,15 @@ public sealed class DiscoveryCandidatesServiceTests
} }
[Fact] [Fact]
public async Task Set_WrongTask_ReturnsNull() public async Task Set_WrongTask_ThrowsNotFound()
{ {
(DiscoveryCandidatesService service, TestDiscoveryStore store) = Create(); (DiscoveryCandidatesService service, TestDiscoveryStore store) = Create();
store.SeedTask(Task("dt_1")); store.SeedTask(Task("dt_1"));
store.SeedTask(Task("dt_2")); store.SeedTask(Task("dt_2"));
store.SeedCandidate(Candidate("-1001", "dt_1")); store.SeedCandidate(Candidate("-1001", "dt_1"));
DiscoveryCandidateDto? candidate = await service.SetAsync( await Assert.ThrowsAsync<NotFoundException>(() => service.SetAsync(
"dt_2", "-1001", new DiscoveryCandidatePatch { Participants = 10 }, CancellationToken.None); "dt_2", "-1001", new DiscoveryCandidatePatch { Participants = 10 }, CancellationToken.None));
Assert.Null(candidate); // кандидат другой задачи (python L465466)
} }
[Fact] [Fact]