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