diff --git a/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs b/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs index f46981d..d17eeaa 100644 --- a/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs @@ -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(); - 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(); - 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) { diff --git a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryCandidatesService.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryCandidatesService.cs index ecabc16..6115bad 100644 --- a/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryCandidatesService.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryCandidatesService.cs @@ -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 = "Задача поиска"; + /// /// 400 mark_rejected /// @@ -53,10 +58,12 @@ public sealed class DiscoveryCandidatesService( /// Кандидат по dialog_id. /// /// Подписанный id источника. - /// Кандидат или null (404 «Кандидат не найден»). - public Task GetAsync(string dialogId, CancellationToken ct) + /// Кандидат. + /// Кандидат не найден. + public async Task GetAsync(string dialogId, CancellationToken ct) { - return store.GetCandidateAsync(dialogId, ct); + return await store.GetCandidateAsync(dialogId, ct).ConfigureAwait(false) + ?? throw new NotFoundException(CandidateEntityName, dialogId); } /// @@ -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); } /// @@ -134,18 +139,21 @@ public sealed class DiscoveryCandidatesService( /// Id задачи. /// Подписанный id источника. /// Изменяемые поля (null — не менять). - /// Обновлённый кандидат либо null — задачи/кандидата нет (или кандидат другой задачи). - public async Task SetAsync( + /// Обновлённый кандидат. + /// Задача или кандидат не найдены. + public async Task 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); } /// @@ -167,9 +176,10 @@ public sealed class DiscoveryCandidatesService( /// /// Подписанный id источника. /// Новый статус: new|review. - /// Обновлённый кандидат либо null (кандидата нет). + /// Обновлённый кандидат. + /// Кандидат не найден. /// Статус не new/review. - public async Task SetStatusAsync( + public async Task 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); } /// @@ -203,17 +211,15 @@ public sealed class DiscoveryCandidatesService( /// /// Подписанный id источника. /// True — авто-вступление воркера; false — ручное. - /// Кандидат в joined либо null (кандидата нет). Повторный вызов для joined — идемпотентен. - public async Task MarkJoinedAsync( + /// Кандидат в joined. Повторный вызов для joined — идемпотентен. + /// Кандидат не найден. + public async Task 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); } /// @@ -232,18 +239,16 @@ public sealed class DiscoveryCandidatesService( /// /// Подписанный id источника. /// Причина отклонения (в лог/чёрный список; «отклонено вручную» — эндпоинт reject). - /// Кандидат в rejected либо null (кандидата нет). Повторный вызов для rejected — идемпотентен. + /// Кандидат в rejected. Повторный вызов для rejected — идемпотентен. + /// Кандидат не найден. /// Источник уже joined — отклонить нельзя. - public async Task MarkRejectedAsync( + public async Task 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); } /// diff --git a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryCandidatesServiceTests.cs b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryCandidatesServiceTests.cs index 3f68ee5..61f3931 100644 --- a/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryCandidatesServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/Modules/Discovery/DiscoveryCandidatesServiceTests.cs @@ -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(() => 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( + () => 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( + () => 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 L465–466) + await Assert.ThrowsAsync(() => service.SetAsync( + "dt_2", "-1001", new DiscoveryCandidatePatch { Participants = 10 }, CancellationToken.None)); } [Fact]