Merge branch 'main' into t2_grpc_retry
ci / build-test (pull_request) Successful in 3m0s

This commit is contained in:
2026-09-13 14:54:58 +03:00
57 changed files with 702 additions and 452 deletions
@@ -0,0 +1,83 @@
using System.Text.Json;
using Deal.Api.Middleware;
using Deal.SharedKernel.Errors;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Logging;
using NSubstitute;
namespace Deal.Tests.Unit.Api;
/// <summary>
/// Тесты обработчика необработанных исключений HTTP.
/// </summary>
public sealed class DealExceptionHandlerTests
{
[Fact]
public async Task NotFound_MapsTo404WithCodeAndRussianDetail()
{
DefaultHttpContext context = CreateContext();
DealExceptionHandler handler = new(Substitute.For<ILogger<DealExceptionHandler>>());
bool handled = await handler.TryHandleAsync(
context,
new NotFoundException("Карточка", "c_1"),
CancellationToken.None);
Assert.True(handled);
Assert.Equal(StatusCodes.Status404NotFound, context.Response.StatusCode);
(string detail, string code) = await ReadBodyAsync(context);
Assert.Equal(DealErrorCodes.NotFound, code);
Assert.Contains("Карточка", detail);
}
[Fact]
public async Task Unavailable_MapsTo503()
{
DefaultHttpContext context = CreateContext();
DealExceptionHandler handler = new(Substitute.For<ILogger<DealExceptionHandler>>());
await handler.TryHandleAsync(
context,
new ServiceUnavailableException("ИИ"),
CancellationToken.None);
Assert.Equal(StatusCodes.Status503ServiceUnavailable, context.Response.StatusCode);
}
[Fact]
public async Task UnexpectedException_MapsToGeneric500WithoutStackOrDetails()
{
DefaultHttpContext context = CreateContext();
DealExceptionHandler handler = new(Substitute.For<ILogger<DealExceptionHandler>>());
bool handled = await handler.TryHandleAsync(
context,
new InvalidOperationException("секретная внутренняя деталь"),
CancellationToken.None);
Assert.True(handled);
Assert.Equal(StatusCodes.Status500InternalServerError, context.Response.StatusCode);
(string detail, string code) = await ReadBodyAsync(context);
Assert.Equal(DealErrorCodes.Internal, code);
Assert.DoesNotContain("секретная внутренняя деталь", detail);
Assert.DoesNotContain("at ", detail);
}
private static DefaultHttpContext CreateContext()
{
var context = new DefaultHttpContext();
context.Response.Body = new MemoryStream();
return context;
}
private static async Task<(string Detail, string Code)> ReadBodyAsync(HttpContext context)
{
context.Response.Body.Seek(0, SeekOrigin.Begin);
using var reader = new StreamReader(context.Response.Body);
string json = await reader.ReadToEndAsync();
using JsonDocument document = JsonDocument.Parse(json);
return (
document.RootElement.GetProperty("detail").GetString() ?? string.Empty,
document.RootElement.GetProperty("code").GetString() ?? string.Empty);
}
}
@@ -39,7 +39,7 @@ public sealed class DiscoveryWorkerSchedulerTests
TestDiscoveryStore StoreB,
TestDiscoveryGateway GatewayA,
TestDiscoveryGateway GatewayB,
TenantContext TenantContext,
ITenantContext TenantContext,
ListLogger Logs);
[Fact]
@@ -91,7 +91,7 @@ public sealed class DiscoveryWorkerSchedulerTests
private static Context CreateContext()
{
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
var storeA = new TestDiscoveryStore();
var storeB = new TestDiscoveryStore();
var settingsA = new TestSettingsStore();
@@ -41,7 +41,7 @@ public sealed class MlOutboxFlushSchedulerTests
{
var store = new TestMlLearningStore();
SeedRows(store, count: 25, prefix: "a");
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new TestTenantRepository(Tenant(TenantA)),
@@ -67,7 +67,7 @@ public sealed class MlOutboxFlushSchedulerTests
service.TrainUnavailable = true;
var store = new TestMlLearningStore();
SeedRows(store, count: 5, prefix: "a");
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new TestTenantRepository(Tenant(TenantA)),
@@ -95,7 +95,7 @@ public sealed class MlOutboxFlushSchedulerTests
SeedRows(storeA, count: 12, prefix: "a");
var storeB = new TestMlLearningStore();
SeedRows(storeB, count: 3, prefix: "b");
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)),
@@ -121,7 +121,7 @@ public sealed class MlOutboxFlushSchedulerTests
{
var store = new TestMlLearningStore();
SeedRows(store, count: 105, prefix: "a");
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
port,
new TestTenantRepository(Tenant(TenantA)),
@@ -148,7 +148,7 @@ public sealed class MlOutboxFlushSchedulerTests
private static ServiceProvider BuildProvider(
int port,
TestTenantRepository tenants,
TenantContext tenantContext,
ITenantContext tenantContext,
Dictionary<Guid, TestMlLearningStore> storesByTenant)
{
var services = new ServiceCollection();
@@ -1,9 +1,10 @@
using Deal.Contracts.Integrations.Models;
using Deal.Tests.Unit.Support;
using Deal.Modules.Cards.Application.Sources;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Modules.Settings;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Contracts;
@@ -343,13 +344,13 @@ public sealed class CardsServiceTests
}
[Fact]
public async Task Trash_CardMissing_ReturnsNull()
public async Task Trash_CardMissing_ThrowsNotFound()
{
(CardsService service, _, _, TestMlClient ml) = Create();
CardDto? result = await service.TrashCardAsync("l_ghost", CancellationToken.None);
await Assert.ThrowsAsync<NotFoundException>(
() => service.TrashCardAsync("l_ghost", CancellationToken.None));
Assert.Null(result); // эндпоинт отвечает 404 «Карточка не найдена»
Assert.Empty(ml.Pushed);
}
@@ -425,13 +426,12 @@ public sealed class CardsServiceTests
}
[Fact]
public async Task Restore_CardMissing_ReturnsNull()
public async Task Restore_CardMissing_ThrowsNotFound()
{
(CardsService service, _, _, _) = Create();
string? back = await service.RestoreCardAsync("l_ghost", CancellationToken.None);
Assert.Null(back); // эндпоинт отвечает 404 «Карточка не найдена»
await Assert.ThrowsAsync<NotFoundException>(
() => service.RestoreCardAsync("l_ghost", CancellationToken.None));
}
@@ -1,8 +1,9 @@
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.Modules.Settings;
using Deal.Tests.Unit.Support;
namespace Deal.Tests.Unit.Modules.Discovery;
@@ -131,14 +132,13 @@ public sealed class DiscoveryTasksServiceTests
}
[Fact]
public async Task Patch_MissingTask_ReturnsNull()
public async Task Patch_MissingTask_ThrowsNotFound()
{
(DiscoveryTasksService service, _, _) = Create();
DiscoveryTaskDto? patched = await service.PatchAsync(
"dt_missing", new DiscoveryTaskPatch { Name = "Новое" }, CancellationToken.None);
Assert.Null(patched);
await Assert.ThrowsAsync<NotFoundException>(
() => service.PatchAsync(
"dt_missing", new DiscoveryTaskPatch { Name = "Новое" }, CancellationToken.None));
}
[Fact]
@@ -201,13 +201,12 @@ public sealed class DiscoveryTasksServiceTests
}
[Fact]
public async Task Start_MissingTask_ReturnsNull()
public async Task Start_MissingTask_ThrowsNotFound()
{
(DiscoveryTasksService service, _, _) = Create();
DiscoveryTaskDto? task = await service.StartAsync("dt_missing", CancellationToken.None);
Assert.Null(task);
await Assert.ThrowsAsync<NotFoundException>(
() => service.StartAsync("dt_missing", CancellationToken.None));
}
[Fact]
@@ -281,9 +280,8 @@ public sealed class DiscoveryTasksServiceTests
store.SeedCandidate(Candidate("c_2", "dt_2"));
await store.Store.UpsertBlacklistAsync("c_1", "Источник", "причина", CancellationToken.None);
bool deleted = await service.DeleteAsync("dt_1", CancellationToken.None);
await service.DeleteAsync("dt_1", CancellationToken.None);
Assert.True(deleted);
Assert.Single(store.Tasks); // dt_2 осталась
Assert.Equal("dt_2", Assert.Single(store.Tasks).Id);
Assert.Single(store.Candidates); // кандидат dt_2 остался
@@ -292,13 +290,12 @@ public sealed class DiscoveryTasksServiceTests
}
[Fact]
public async Task Delete_MissingTask_ReturnsFalse()
public async Task Delete_MissingTask_ThrowsNotFound()
{
(DiscoveryTasksService service, _, _) = Create();
bool deleted = await service.DeleteAsync("dt_missing", CancellationToken.None);
Assert.False(deleted);
await Assert.ThrowsAsync<NotFoundException>(
() => service.DeleteAsync("dt_missing", CancellationToken.None));
}
[Fact]
@@ -1,6 +1,7 @@
using System.Text;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Settings;
@@ -73,14 +74,13 @@ public sealed class CardsServiceFilesTests
}
[Fact]
public async Task Add_CardMissing_ReturnsNullAndDoesNotWriteObject()
public async Task Add_CardMissing_ThrowsAndDoesNotWriteObject()
{
(CardsService service, TestKanjStore store, TestFileStorage storage) = Create();
CardFileDto? entry = await service.AddFileAsync(
"c_missing", "photo.png", "image/png", new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None);
await Assert.ThrowsAsync<NotFoundException>(() => service.AddFileAsync(
"c_missing", "photo.png", "image/png", new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None));
Assert.Null(entry); // эндпоинт отвечает 404 «Карточка не найдена»
Assert.Empty(storage.StoredObjectKeys); // «add на несуществующей карточке не пишет объект»
Assert.Empty(store.CardDtos);
}
@@ -151,25 +151,23 @@ public sealed class CardsServiceFilesTests
}
[Fact]
public async Task GetEntry_CardMissing_ReturnsNull()
public async Task GetEntry_CardMissing_ThrowsNotFound()
{
(CardsService service, _, _) = Create();
CardFileDto? entry = await service.GetFileEntryAsync("c_missing", "pf_1", CancellationToken.None);
Assert.Null(entry); // 404 «Карточка не найдена» у эндпоинта
await Assert.ThrowsAsync<NotFoundException>(
() => service.GetFileEntryAsync("c_missing", "pf_1", CancellationToken.None));
}
[Fact]
public async Task GetEntry_FileNotInMetadata_ReturnsNull()
public async Task GetEntry_FileNotInMetadata_ThrowsNotFound()
{
(CardsService service, TestKanjStore store, _) = Create();
store.SeedCard(Card("c_1")
with { Files = new[] { new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "k") } });
CardFileDto? entry = await service.GetFileEntryAsync("c_1", "pf_ghost", CancellationToken.None);
Assert.Null(entry); // файла нет в метаданных карточки — 404-семантика
await Assert.ThrowsAsync<NotFoundException>(
() => service.GetFileEntryAsync("c_1", "pf_ghost", CancellationToken.None));
}
@@ -225,13 +223,13 @@ public sealed class CardsServiceFilesTests
}
[Fact]
public async Task Remove_CardMissing_ReturnsNullWithoutStorageDelete()
public async Task Remove_CardMissing_ThrowsWithoutStorageDelete()
{
(CardsService service, _, TestFileStorage storage) = Create();
CardDto? card = await service.RemoveFileAsync("c_missing", "pf_1", CancellationToken.None);
await Assert.ThrowsAsync<NotFoundException>(
() => service.RemoveFileAsync("c_missing", "pf_1", CancellationToken.None));
Assert.Null(card); // эндпоинт отвечает 404 «Карточка не найдена»
Assert.Empty(storage.DeletedKeys);
}
@@ -1,6 +1,7 @@
using System.Text.Json;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Contracts;
using Deal.Tests.Unit.Modules.Settings;
@@ -99,13 +100,13 @@ public sealed class CardsServiceSelectedTests
[Fact]
public async Task TakeCard_CardMissing_ReturnsNullAndCreatesNothing()
public async Task TakeCard_CardMissing_ThrowsAndCreatesNothing()
{
(CardsService service, TestKanjStore store, _, _) = Create();
CardDto? card = await service.TakeCardAsync("c_missing", CancellationToken.None);
await Assert.ThrowsAsync<NotFoundException>(
() => service.TakeCardAsync("c_missing", CancellationToken.None));
Assert.Null(card);
Assert.Empty(store.CardDtos);
}
@@ -122,8 +123,7 @@ public sealed class CardsServiceSelectedTests
stack: new[] { "Python", "aiogram" },
budget: new CardBudgetDto(From: 1600, To: 2200, Cur: "USD")));
CardDto card = await service.TakeCardAsync("c_1", CancellationToken.None)
?? throw new InvalidOperationException("take вернул null при существующей карточке");
CardDto card = await service.TakeCardAsync("c_1", CancellationToken.None);
Assert.Equal("c_1", card.Id);
Assert.Equal("planned", card.Col);
@@ -182,8 +182,7 @@ public sealed class CardsServiceSelectedTests
("tzText", "ТЗ"),
("stack", new[] { "C#", ".NET" }), // стек — полная замена массива
("budget", new { from = 500, cur = "EUR" })),
CancellationToken.None)
?? throw new InvalidOperationException("patch вернул null при существующей карточке");
CancellationToken.None);
Assert.Equal("Новый заголовок", card.Title);
Assert.Equal(string.Empty, card.Summary); // summary очищена пустой строкой
@@ -255,16 +254,14 @@ public sealed class CardsServiceSelectedTests
}
[Fact]
public async Task Patch_CardMissing_ReturnsNull()
public async Task Patch_CardMissing_ThrowsNotFound()
{
(CardsService service, _, _, _) = Create();
CardDto? card = await service.PatchCardAsync(
await Assert.ThrowsAsync<NotFoundException>(() => service.PatchCardAsync(
"c_missing",
PatchBody(("title", "Т")),
CancellationToken.None);
Assert.Null(card);
CancellationToken.None));
}
@@ -1,6 +1,7 @@
using System.Text.Json;
using Deal.Modules.Kanban.Application.Models;
using Deal.Modules.Kanban.Application.Services;
using Deal.SharedKernel.Errors;
using Deal.Tests.Unit.Modules.Settings;
namespace Deal.Tests.Unit.Support;
@@ -237,14 +238,12 @@ public sealed class ContainersServiceTests
}
[Fact]
public async Task Patch_UnknownContainer_ReturnsNull()
public async Task Patch_UnknownContainer_ThrowsNotFound()
{
(ContainersService service, _, _) = Create();
ContainerDto? result = await service.PatchAsync(
"b_missing", Patch(name: "X"), CancellationToken.None);
Assert.Null(result); // эндпоинт отвечает 404 «Контейнер не найден»
await Assert.ThrowsAsync<NotFoundException>(
() => service.PatchAsync("b_missing", Patch(name: "X"), CancellationToken.None));
}
[Fact]
@@ -0,0 +1,34 @@
using Deal.SharedKernel.Resources;
namespace Deal.Tests.Unit.Support;
/// <summary>
/// Тесты ресурсов текстов ошибок (ErrorMessages.resx).
/// </summary>
public sealed class ErrorResourcesTests
{
[Fact]
public void Format_KnownKey_ReturnsRussianText()
{
string text = ErrorResources.Format(ErrorResourceKeys.UnexpectedError);
Assert.Contains("Внутренняя ошибка", text);
}
[Fact]
public void Format_TemplateWithArgs_SubstitutesPlaceholders()
{
string text = ErrorResources.Format(ErrorResourceKeys.NotFoundEntityWithId, "Карточка", "c_1");
Assert.Contains("Карточка", text);
Assert.Contains("c_1", text);
}
[Fact]
public void Format_UnknownKey_ReturnsKey()
{
string text = ErrorResources.Format("NoSuchKey");
Assert.Equal("NoSuchKey", text);
}
}
@@ -47,7 +47,7 @@ public sealed class PipelineWorkerSchedulerTests
TestKanjStore KanjB,
SseSubscription SubscriptionB,
PipelinePumpGate PumpGate,
TenantContext TenantContext,
ITenantContext TenantContext,
ListLogger Logs);
// ─── Цикл: pump каждого тенанта в собственном scope + new_card ─────────
@@ -149,7 +149,7 @@ public sealed class PipelineWorkerSchedulerTests
private static Context CreateContext(bool withThrowingQueueReadA = false)
{
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
var pipelineA = new TestPipelineStore(throwOnList: withThrowingQueueReadA);
var pipelineB = new TestPipelineStore();
var kanjA = new TestKanjStore();
@@ -46,7 +46,7 @@ public sealed class StorageTickSchedulerTests
TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var settings = new TestSettingsStore();
var tenants = new TestTenantRepository(Tenant(TenantA), Tenant(TenantB));
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(tenants, tenantContext, storeA, storeB, settings);
SseBroker broker = provider.GetRequiredService<SseBroker>();
@@ -74,7 +74,7 @@ public sealed class StorageTickSchedulerTests
var storeB = new TestKanjStore();
storeB.SeedCard(Card("l_b_fresh", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromHours(1))));
var settings = new TestSettingsStore();
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)),
tenantContext,
@@ -100,7 +100,7 @@ public sealed class StorageTickSchedulerTests
{
// У тенанта A настройки падают (имитация сбоя схемы/БД) — тик A логирует ошибку, B обрабатывается.
TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
var settingsByTenant = new Dictionary<Guid, ISettingsStore>
{
[TenantA] = new ThrowingSettingsStore(),
@@ -133,7 +133,7 @@ public sealed class StorageTickSchedulerTests
[Fact]
public async Task RunCycle_TenantListFailure_DoesNotThrow()
{
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new ThrowingTenantRepository(),
tenantContext,
@@ -152,7 +152,7 @@ public sealed class StorageTickSchedulerTests
{
var kanjStore = new TestKanjStore();
var settings = new TestSettingsStore();
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
var pipelineStoreA = new TestPipelineStore();
long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds();
pipelineStoreA.SeedRejected(Rejected("r_old_a", (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds)));
@@ -186,7 +186,7 @@ public sealed class StorageTickSchedulerTests
cardStoreA.SeedCard(HoldCard("c_a_future", title: "Будущий", reminderAtMs: NowMs() + 60_000));
cardStoreA.SeedCard(HoldCard("c_a_work", stage: "work", title: "В работе", reminderAtMs: NowMs() - 60_000));
var cardStoreB = new TestKanjStore();
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
tenantContext,
@@ -221,7 +221,7 @@ public sealed class StorageTickSchedulerTests
cardStoreA.SeedCard(HoldCard("c_past", title: "Старое", reminderAtMs: NowMs() - 60_000));
var settingsA = new TestSettingsStore();
settingsA.Preload(SettingsKeys.RemindersEnabled, "false");
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA)).Repository,
tenantContext,
@@ -247,7 +247,7 @@ public sealed class StorageTickSchedulerTests
// и тик A завершается, тенант B обрабатывается (автоархив + тост) — проход жив.
var cardStoreA = new TestKanjStore(throwOnDueReminders: true);
TestKanjStore storeB = StoreWithExpiredInboxCard("l_b_old");
var tenantContext = new TenantContext();
ITenantContext tenantContext = new TenantContext();
await using ServiceProvider provider = BuildProvider(
new TestTenantRepository(Tenant(TenantA), Tenant(TenantB)).Repository,
tenantContext,
@@ -280,7 +280,7 @@ public sealed class StorageTickSchedulerTests
// Возвращает: Провайдер с зарегистрированными сервисами теста.
private static ServiceProvider BuildProvider(
TestTenantRepository tenants,
TenantContext tenantContext,
ITenantContext tenantContext,
TestKanjStore storeA,
TestKanjStore storeB,
TestSettingsStore settings)
@@ -301,7 +301,7 @@ public sealed class StorageTickSchedulerTests
// Возвращает: Провайдер с зарегистрированными сервисами теста.
private static ServiceProvider BuildProvider(
ITenantRepository tenants,
TenantContext tenantContext,
ITenantContext tenantContext,
Dictionary<Guid, TestKanjStore> storesByTenant,
Dictionary<Guid, ISettingsStore> settingsByTenant,
Dictionary<Guid, TestPipelineStore>? pipelineStoresByTenant = null)