diff --git a/src/core/Deal.Api/AdminTickOrchestrator.cs b/src/core/Deal.Api/AdminTickOrchestrator.cs index e3fbb46..535a3ae 100644 --- a/src/core/Deal.Api/AdminTickOrchestrator.cs +++ b/src/core/Deal.Api/AdminTickOrchestrator.cs @@ -1,8 +1,13 @@ using Deal.Api.Events; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; namespace Deal.Api; diff --git a/src/core/Deal.Api/Configuration/CookieOptions.cs b/src/core/Deal.Api/Configuration/CookieOptions.cs index 13a56b7..9a6d188 100644 --- a/src/core/Deal.Api/Configuration/CookieOptions.cs +++ b/src/core/Deal.Api/Configuration/CookieOptions.cs @@ -1,4 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Api.Configuration; diff --git a/src/core/Deal.Api/Configuration/OperatorCookieOptions.cs b/src/core/Deal.Api/Configuration/OperatorCookieOptions.cs index 37c489c..ccc61f7 100644 --- a/src/core/Deal.Api/Configuration/OperatorCookieOptions.cs +++ b/src/core/Deal.Api/Configuration/OperatorCookieOptions.cs @@ -1,4 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Api.Configuration; diff --git a/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs b/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs index 27e8bb8..6a1e105 100644 --- a/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs +++ b/src/core/Deal.Api/Endpoints/AiCheckEndpoint.cs @@ -1,7 +1,9 @@ using System.Text.Json; using Deal.Api.Http; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Api.Endpoints; diff --git a/src/core/Deal.Api/Endpoints/AuthEndpoints.cs b/src/core/Deal.Api/Endpoints/AuthEndpoints.cs index 2ecebc8..c60c091 100644 --- a/src/core/Deal.Api/Endpoints/AuthEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/AuthEndpoints.cs @@ -1,7 +1,10 @@ using Deal.Api.Http; using Deal.Api.Middleware; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Microsoft.Extensions.Options; // Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом. using CookieOptions = Deal.Api.Configuration.CookieOptions; diff --git a/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs b/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs index 3b61fa7..6302a73 100644 --- a/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/CardDetailsEndpoints.cs @@ -3,9 +3,16 @@ using Deal.Api.Endpoints.RequestModels; using Deal.Api.Http; using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Api.Endpoints; diff --git a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs index a945a76..ab599b4 100644 --- a/src/core/Deal.Api/Endpoints/CardsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/CardsEndpoints.cs @@ -4,11 +4,20 @@ using Deal.Api.Http; using Deal.Modules.Cards.Application.Abstractions; using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Api.Endpoints; diff --git a/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs b/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs index 1655bb8..27b5331 100644 --- a/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/ContainersEndpoints.cs @@ -1,9 +1,16 @@ using System.Text.Json; using Deal.Api.Endpoints.RequestModels; using Deal.Api.Http; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Api.Endpoints; diff --git a/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs b/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs index f1b2dc4..59455c9 100644 --- a/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/DiscoveryEndpoints.cs @@ -3,10 +3,16 @@ using Deal.Api.Endpoints.RequestModels; using Deal.Api.Http; using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; -using Deal.Modules.Discovery.Application; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Settings.Application; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; using Deal.Modules.Telegram.Application; namespace Deal.Api.Endpoints; diff --git a/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs b/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs index 61a44e6..c1cfe2c 100644 --- a/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/FilterTesterEndpoints.cs @@ -1,6 +1,8 @@ using Deal.Api.Http; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Api.Endpoints; diff --git a/src/core/Deal.Api/Endpoints/JoinEndpoint.cs b/src/core/Deal.Api/Endpoints/JoinEndpoint.cs index 2cfb729..09c54fa 100644 --- a/src/core/Deal.Api/Endpoints/JoinEndpoint.cs +++ b/src/core/Deal.Api/Endpoints/JoinEndpoint.cs @@ -1,6 +1,9 @@ using Deal.Api.Http; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Api.Endpoints; diff --git a/src/core/Deal.Api/Endpoints/MlEndpoints.cs b/src/core/Deal.Api/Endpoints/MlEndpoints.cs index b3642e0..0862890 100644 --- a/src/core/Deal.Api/Endpoints/MlEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/MlEndpoints.cs @@ -1,8 +1,10 @@ using Deal.Api.Http; using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; -using Deal.Modules.Pipeline.Application; +using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; namespace Deal.Api.Endpoints; diff --git a/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs index 94b77d3..d71a6e1 100644 --- a/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorAnalyticsEndpoints.cs @@ -1,168 +1,171 @@ -using Deal.Api.Http; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Api.Endpoints; - -/// -/// Операторские read-only эндпоинты аналитики: /api/operator/analytics/{overview,tokens,activity} (этап 10, T3). -/// -/// -/// Только под операторской сессией: без неё 401 «Требуется вход оператора» (как прочие /api/operator/*). -/// Ничего не меняет (read-only). groupBy — day|tenant|provider|model (неизвестное — 400 {detail}); from/to — -/// ISO-8601 (включительно), как у аудита; activity поддерживает фильтры eventType/actorType/actorId/tenantId, -/// limit (1..500) и offset. Все ответы — camelCase (контракт: docs/architecture/2026-09-10-operator-analytics-contract.md). -/// -public static class OperatorAnalyticsEndpoints -{ - // Префикс группы аналитики (Ruling 4 этапа 10). - private const string AnalyticsGroupPrefix = "/api/operator/analytics"; - - // OpenAPI-тег группы. - private const string OperatorOpenApiTag = "operator"; - - // Группировка расхода токенов по умолчанию (сутки). - private const string DefaultGroupBy = TokenUsageGroupBys.Day; - - // 400 tokens: неизвестная группировка. - private const string InvalidGroupByDetail = "Неизвестная группировка (day|tenant|provider|model)"; - - /// - /// Регистрирует группу /api/operator/analytics: overview/tokens/activity. - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapOperatorAnalyticsEndpoints(this IEndpointRouteBuilder app) - { - var group = app.MapGroup(AnalyticsGroupPrefix).WithTags(OperatorOpenApiTag); - group.MapGet("/overview", OverviewAsync); - group.MapGet("/tokens", TokensAsync); - group.MapGet("/activity", ActivityAsync); - group.MapGet("/suspicious", SuspiciousAsync); - return app; - } - - // GET /api/operator/analytics/suspicious?from=&to=: находки детектора подозрительной активности (§10.5). - // from: Начало окна анализа (включительно; ISO-8601); null — последние 24 часа. - // to: Конец окна анализа (включительно; ISO-8601); null — «сейчас». - // context: Контекст запроса. - // suspiciousService: Детектор подозрительной активности (scoped). - // ct: Токен отмены. - // Возвращает: 200 сводка находок или 401 без операторской сессии. - private static async Task SuspiciousAsync( - DateTimeOffset? from, - DateTimeOffset? to, - HttpContext context, - SuspiciousActivityService suspiciousService, - CancellationToken ct) - { - if (context.GetCurrentOperator() is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - SuspiciousActivityDto report = await suspiciousService.AnalyzeAsync(from, to, ct); - return Results.Ok(report); - } - - // GET /api/operator/analytics/overview?from=&to=: сводка (тенанты, токены, события, входы/выходы). - // from: Начало периода (включительно; ISO-8601). - // to: Конец периода (включительно; ISO-8601). - // context: Контекст запроса. - // analyticsService: Сервис аналитики (scoped). - // ct: Токен отмены. - // Возвращает: 200 сводка или 401 без операторской сессии. - private static async Task OverviewAsync( - DateTimeOffset? from, - DateTimeOffset? to, - HttpContext context, - AnalyticsService analyticsService, - CancellationToken ct) - { - if (context.GetCurrentOperator() is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - AnalyticsOverviewDto overview = await analyticsService.OverviewAsync(from, to, ct); - return Results.Ok(overview); - } - - // GET /api/operator/analytics/tokens?groupBy=&tenantId=&from=&to=: агрегаты расхода токенов. - // groupBy: Группировка day|tenant|provider|model (дефолт day). - // tenantId: Тенант (равенство; пусто — все тенанты). - // from: Начало периода (включительно; ISO-8601). - // to: Конец периода (включительно; ISO-8601). - // context: Контекст запроса. - // analyticsService: Сервис аналитики (scoped). - // ct: Токен отмены. - // Возвращает: 200 агрегаты, 400 неизвестная группировка или 401 без операторской сессии. - private static async Task TokensAsync( - string? groupBy, - Guid? tenantId, - DateTimeOffset? from, - DateTimeOffset? to, - HttpContext context, - AnalyticsService analyticsService, - CancellationToken ct) - { - if (context.GetCurrentOperator() is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - string normalizedGroupBy = string.IsNullOrWhiteSpace(groupBy) ? DefaultGroupBy : groupBy; - if (!IsKnownGroupBy(normalizedGroupBy)) - { - return EndpointResults.BadRequest(InvalidGroupByDetail); - } - - AnalyticsTokensDto tokens = await analyticsService.TokensAsync(normalizedGroupBy, tenantId, from, to, ct); - return Results.Ok(tokens); - } - - // GET /api/operator/analytics/activity?eventType=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=: лента действий. - // eventType: Тип события (равенство; пусто — без фильтра). - // actorType: Тип актора operator|tenant|system (равенство). - // actorId: Идентификатор актора (равенство). - // tenantId: Тенант (равенство). - // from: Нижняя граница At (включительно; ISO-8601). - // to: Верхняя граница At (включительно; ISO-8601). - // limit: Размер страницы (дефолт 100, кламп 1..500). - // offset: Смещение страницы (≥0). - // context: Контекст запроса. - // analyticsService: Сервис аналитики (scoped). - // ct: Токен отмены. - // Возвращает: 200 {items, total, limit, offset} или 401 без операторской сессии. - private static async Task ActivityAsync( - string? eventType, - string? actorType, - Guid? actorId, - Guid? tenantId, - DateTimeOffset? from, - DateTimeOffset? to, - int? limit, - int? offset, - HttpContext context, - AnalyticsService analyticsService, - CancellationToken ct) - { - if (context.GetCurrentOperator() is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - AnalyticsActivityDto activity = await analyticsService.ActivityAsync( - eventType, actorType, actorId, tenantId, from, to, limit, offset, ct); - return Results.Ok(activity); - } - - /// - /// Известна ли группировка расхода токенов (day|tenant|provider|model). - /// - /// Значение группировки. - /// True — поддерживаемая группировка. - public static bool IsKnownGroupBy(string groupBy) => - groupBy is TokenUsageGroupBys.Day or TokenUsageGroupBys.Tenant - or TokenUsageGroupBys.Provider or TokenUsageGroupBys.Model; -} +using Deal.Api.Http; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Api.Endpoints; + +/// +/// Операторские read-only эндпоинты аналитики: /api/operator/analytics/{overview,tokens,activity} (этап 10, T3). +/// +/// +/// Только под операторской сессией: без неё 401 «Требуется вход оператора» (как прочие /api/operator/*). +/// Ничего не меняет (read-only). groupBy — day|tenant|provider|model (неизвестное — 400 {detail}); from/to — +/// ISO-8601 (включительно), как у аудита; activity поддерживает фильтры eventType/actorType/actorId/tenantId, +/// limit (1..500) и offset. Все ответы — camelCase (контракт: docs/architecture/2026-09-10-operator-analytics-contract.md). +/// +public static class OperatorAnalyticsEndpoints +{ + // Префикс группы аналитики (Ruling 4 этапа 10). + private const string AnalyticsGroupPrefix = "/api/operator/analytics"; + + // OpenAPI-тег группы. + private const string OperatorOpenApiTag = "operator"; + + // Группировка расхода токенов по умолчанию (сутки). + private const string DefaultGroupBy = TokenUsageGroupBys.Day; + + // 400 tokens: неизвестная группировка. + private const string InvalidGroupByDetail = "Неизвестная группировка (day|tenant|provider|model)"; + + /// + /// Регистрирует группу /api/operator/analytics: overview/tokens/activity. + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapOperatorAnalyticsEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup(AnalyticsGroupPrefix).WithTags(OperatorOpenApiTag); + group.MapGet("/overview", OverviewAsync); + group.MapGet("/tokens", TokensAsync); + group.MapGet("/activity", ActivityAsync); + group.MapGet("/suspicious", SuspiciousAsync); + return app; + } + + // GET /api/operator/analytics/suspicious?from=&to=: находки детектора подозрительной активности (§10.5). + // from: Начало окна анализа (включительно; ISO-8601); null — последние 24 часа. + // to: Конец окна анализа (включительно; ISO-8601); null — «сейчас». + // context: Контекст запроса. + // suspiciousService: Детектор подозрительной активности (scoped). + // ct: Токен отмены. + // Возвращает: 200 сводка находок или 401 без операторской сессии. + private static async Task SuspiciousAsync( + DateTimeOffset? from, + DateTimeOffset? to, + HttpContext context, + SuspiciousActivityService suspiciousService, + CancellationToken ct) + { + if (context.GetCurrentOperator() is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + SuspiciousActivityDto report = await suspiciousService.AnalyzeAsync(from, to, ct); + return Results.Ok(report); + } + + // GET /api/operator/analytics/overview?from=&to=: сводка (тенанты, токены, события, входы/выходы). + // from: Начало периода (включительно; ISO-8601). + // to: Конец периода (включительно; ISO-8601). + // context: Контекст запроса. + // analyticsService: Сервис аналитики (scoped). + // ct: Токен отмены. + // Возвращает: 200 сводка или 401 без операторской сессии. + private static async Task OverviewAsync( + DateTimeOffset? from, + DateTimeOffset? to, + HttpContext context, + AnalyticsService analyticsService, + CancellationToken ct) + { + if (context.GetCurrentOperator() is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + AnalyticsOverviewDto overview = await analyticsService.OverviewAsync(from, to, ct); + return Results.Ok(overview); + } + + // GET /api/operator/analytics/tokens?groupBy=&tenantId=&from=&to=: агрегаты расхода токенов. + // groupBy: Группировка day|tenant|provider|model (дефолт day). + // tenantId: Тенант (равенство; пусто — все тенанты). + // from: Начало периода (включительно; ISO-8601). + // to: Конец периода (включительно; ISO-8601). + // context: Контекст запроса. + // analyticsService: Сервис аналитики (scoped). + // ct: Токен отмены. + // Возвращает: 200 агрегаты, 400 неизвестная группировка или 401 без операторской сессии. + private static async Task TokensAsync( + string? groupBy, + Guid? tenantId, + DateTimeOffset? from, + DateTimeOffset? to, + HttpContext context, + AnalyticsService analyticsService, + CancellationToken ct) + { + if (context.GetCurrentOperator() is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + string normalizedGroupBy = string.IsNullOrWhiteSpace(groupBy) ? DefaultGroupBy : groupBy; + if (!IsKnownGroupBy(normalizedGroupBy)) + { + return EndpointResults.BadRequest(InvalidGroupByDetail); + } + + AnalyticsTokensDto tokens = await analyticsService.TokensAsync(normalizedGroupBy, tenantId, from, to, ct); + return Results.Ok(tokens); + } + + // GET /api/operator/analytics/activity?eventType=&actorType=&actorId=&tenantId=&from=&to=&limit=&offset=: лента действий. + // eventType: Тип события (равенство; пусто — без фильтра). + // actorType: Тип актора operator|tenant|system (равенство). + // actorId: Идентификатор актора (равенство). + // tenantId: Тенант (равенство). + // from: Нижняя граница At (включительно; ISO-8601). + // to: Верхняя граница At (включительно; ISO-8601). + // limit: Размер страницы (дефолт 100, кламп 1..500). + // offset: Смещение страницы (≥0). + // context: Контекст запроса. + // analyticsService: Сервис аналитики (scoped). + // ct: Токен отмены. + // Возвращает: 200 {items, total, limit, offset} или 401 без операторской сессии. + private static async Task ActivityAsync( + string? eventType, + string? actorType, + Guid? actorId, + Guid? tenantId, + DateTimeOffset? from, + DateTimeOffset? to, + int? limit, + int? offset, + HttpContext context, + AnalyticsService analyticsService, + CancellationToken ct) + { + if (context.GetCurrentOperator() is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + AnalyticsActivityDto activity = await analyticsService.ActivityAsync( + eventType, actorType, actorId, tenantId, from, to, limit, offset, ct); + return Results.Ok(activity); + } + + /// + /// Известна ли группировка расхода токенов (day|tenant|provider|model). + /// + /// Значение группировки. + /// True — поддерживаемая группировка. + public static bool IsKnownGroupBy(string groupBy) => + groupBy is TokenUsageGroupBys.Day or TokenUsageGroupBys.Tenant + or TokenUsageGroupBys.Provider or TokenUsageGroupBys.Model; +} diff --git a/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs index ab3cbe5..bcab5ed 100644 --- a/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorAuditEndpoints.cs @@ -1,6 +1,9 @@ using Deal.Api.Http; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Api.Endpoints; diff --git a/src/core/Deal.Api/Endpoints/OperatorAuthEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorAuthEndpoints.cs index 35bf1b3..b26be34 100644 --- a/src/core/Deal.Api/Endpoints/OperatorAuthEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorAuthEndpoints.cs @@ -1,171 +1,174 @@ -using Deal.Api.Http; -using Deal.Api.Middleware; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; -using Microsoft.Extensions.Options; -using AspNetCoreCookieOptions = Microsoft.AspNetCore.Http.CookieOptions; -// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасами. -using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions; - -namespace Deal.Api.Endpoints; - -/// -/// HTTP-эндпоинты аутентификации оператора (группа /api/operator/auth). Зеркало AuthEndpoints для операторов (Ruling 1). -/// -/// -/// Оператор ≠ пользователь тенанта: вход по отдельным public-таблицам (OperatorAuthService/IOperatorAuthStore), -/// сессия — в куке deal_operator_session (отдельная от deal_session; 12 ч, httpOnly, SameSite=Lax). -/// Успех-ответы — {ok:true,...}, ошибки — HTTP-код + {"detail":"..."} (Ruling 10). Защищённые -/// ручки (me) требуют операторскую сессию (401 «Требуется вход оператора») — тенантная кука не проходит. -/// Результаты входа пишутся в аудит (operator_login_ok/failed, Task 4/Ruling 4). -/// -public static class OperatorAuthEndpoints -{ - private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора"; - private const string OperatorAuthGroupPrefix = "/api/operator/auth"; - private const string OperatorAuthOpenApiTag = "operator-auth"; - - /// - /// Регистрирует группу /api/operator/auth: login, logout, me. - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapOperatorAuthEndpoints(this IEndpointRouteBuilder app) - { - var group = app.MapGroup(OperatorAuthGroupPrefix).WithTags(OperatorAuthOpenApiTag); - - // Политика "auth" rate limiter (план Task 11, Ruling 5): фиксированное окно 10/мин на IP ручки - // входа оператора; остальные ручки группы — под глобальной API-политикой (по тенанту/IP). - group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy); - group.MapPost("/logout", LogoutAsync); - group.MapGet("/me", MeAsync); - - return app; - } - - // POST /api/operator/auth/login: проверка учётных данных оператора, выдача куки сессии; результат пишется в аудит (Task 4). - // До OperatorAuthService отрабатывает LoginAttemptGuard (5 неудач ip|login за 15 мин → 429, Ruling 5). - private static async Task LoginAsync( - LoginRequest body, - OperatorAuthService operatorAuthService, - AuditService auditService, - IOptions cookieOptions, - HttpContext context, - CancellationToken ct, - LoginAttemptGuard loginAttemptGuard) - { - string? attemptedLogin = NormalizeLogin(body.Login); - - // Защита входа оператора (план Task 11, Ruling 5): зеркало AuthEndpoints — блокировка ключа - // ip|login до проверки учётных данных (в dev при RateLimit:Enabled=false гвард выключен). - if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct)) - { - return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail); - } - - var result = await operatorAuthService.LoginAsync(body.Login, body.Password, ct); - if (result.Login is null || result.Token is null) - { - // Неверные учётные данные оператора — одно сообщение (зеркало AuthEndpoints). - // Аудит operator_login_failed — только для реальной попытки (непустой логин), без пароля (Ruling 4); - // счётчик неудач гварда растёт там же (пустые логины ключа не имеют). - if (attemptedLogin is not null) - { - await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct); - await auditService.AppendAsync(new AuditRecordDto( - AuditEvents.OperatorLoginFailed, - AuditActorTypes.Operator, - ActorId: null, - TenantId: null, - Ip: ClientIp(context), - DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct); - } - - return EndpointResults.Unauthorized(InvalidCredentialsDetail); - } - - // Успешный вход оператора сбрасывает счётчик неудач ключа ip|login (Ruling 5). - await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct); - - await auditService.AppendAsync(new AuditRecordDto( - AuditEvents.OperatorLoginOk, - AuditActorTypes.Operator, - ActorId: result.OperatorId, - TenantId: null, - Ip: ClientIp(context), - DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct); - - SetOperatorSessionCookie(context, cookieOptions.Value, result.Token); - return Results.Ok(new { ok = true, login = result.Login }); - } - - // POST /api/operator/auth/logout: удаление операторской сессии по токену из куки и очистка куки (всегда ok). - private static async Task LogoutAsync( - OperatorAuthService operatorAuthService, - IOptions cookieOptions, - HttpContext context, - CancellationToken ct) - { - var cookieName = cookieOptions.Value.Name; - var rawToken = context.Request.Cookies[cookieName]; - // Оператор разрешённой сессии — до её удаления (OperatorSessionMiddleware наполнил Items). - CurrentOperator? operatorIdentity = context.GetCurrentOperator(); - await operatorAuthService.LogoutAsync(rawToken, ct); - context.Response.Cookies.Delete(cookieName); - - // Выход оператора (этап 10, T1): событие пишется при живой разрешённой сессии. - if (operatorIdentity is not null) - { - await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct); - } - - return Results.Ok(new { ok = true }); - } - - // GET /api/operator/auth/me: проверка живой операторской сессии (401 без неё, Ruling 1). - private static IResult MeAsync(HttpContext context) - { - var operatorIdentity = context.GetCurrentOperator(); - if (operatorIdentity is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - return Results.Ok(new { login = operatorIdentity.Login, ok = true }); - } - - // Выставляет httpOnly-куку сессии оператора: SameSite=Lax, Path=/, MaxAge=Hours, Secure — из конфига. - // context: Контекст запроса. - // options: Настройки куки из конфигурации (секция OperatorCookies). - // rawToken: Raw-токен операторской сессии. - private static void SetOperatorSessionCookie(HttpContext context, OperatorCookieOptions options, string rawToken) - { - // MaxAge — OperatorCookies:Hours; код-дефолт значения ссылается на - // OperatorAuthService.SessionLifetimeHours (единый источник «12 часов», см. OperatorCookieOptions). - context.Response.Cookies.Append( - options.Name, - rawToken, - new AspNetCoreCookieOptions - { - HttpOnly = true, - SameSite = SameSiteMode.Lax, - Path = "/", - MaxAge = TimeSpan.FromHours(options.Hours), - Secure = options.Secure, - }); - } - - // Нормализованная попытка логина для аудита (нижний регистр/обрезка); null — писать нечего. - // login: Логин из тела запроса. - // Возвращает: Нормализованный логин или null при пустом/пробельном входе. - private static string? NormalizeLogin(string? login) - { - string? normalized = login?.Trim().ToLowerInvariant(); - return string.IsNullOrEmpty(normalized) ? null : normalized; - } - - // IP-адрес клиента для аудита (без порта; null, если недоступен). - // context: Контекст запроса. - // Возвращает: Строковое представление IP или null. - private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); -} +using Deal.Api.Http; +using Deal.Api.Middleware; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; +using Microsoft.Extensions.Options; +using AspNetCoreCookieOptions = Microsoft.AspNetCore.Http.CookieOptions; +// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасами. +using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions; + +namespace Deal.Api.Endpoints; + +/// +/// HTTP-эндпоинты аутентификации оператора (группа /api/operator/auth). Зеркало AuthEndpoints для операторов (Ruling 1). +/// +/// +/// Оператор ≠ пользователь тенанта: вход по отдельным public-таблицам (OperatorAuthService/IOperatorAuthStore), +/// сессия — в куке deal_operator_session (отдельная от deal_session; 12 ч, httpOnly, SameSite=Lax). +/// Успех-ответы — {ok:true,...}, ошибки — HTTP-код + {"detail":"..."} (Ruling 10). Защищённые +/// ручки (me) требуют операторскую сессию (401 «Требуется вход оператора») — тенантная кука не проходит. +/// Результаты входа пишутся в аудит (operator_login_ok/failed, Task 4/Ruling 4). +/// +public static class OperatorAuthEndpoints +{ + private const string InvalidCredentialsDetail = "Неверный логин или пароль оператора"; + private const string OperatorAuthGroupPrefix = "/api/operator/auth"; + private const string OperatorAuthOpenApiTag = "operator-auth"; + + /// + /// Регистрирует группу /api/operator/auth: login, logout, me. + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapOperatorAuthEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup(OperatorAuthGroupPrefix).WithTags(OperatorAuthOpenApiTag); + + // Политика "auth" rate limiter (план Task 11, Ruling 5): фиксированное окно 10/мин на IP ручки + // входа оператора; остальные ручки группы — под глобальной API-политикой (по тенанту/IP). + group.MapPost("/login", LoginAsync).RequireRateLimiting(RateLimitPolicies.AuthPolicy); + group.MapPost("/logout", LogoutAsync); + group.MapGet("/me", MeAsync); + + return app; + } + + // POST /api/operator/auth/login: проверка учётных данных оператора, выдача куки сессии; результат пишется в аудит (Task 4). + // До OperatorAuthService отрабатывает LoginAttemptGuard (5 неудач ip|login за 15 мин → 429, Ruling 5). + private static async Task LoginAsync( + LoginRequest body, + OperatorAuthService operatorAuthService, + AuditService auditService, + IOptions cookieOptions, + HttpContext context, + CancellationToken ct, + LoginAttemptGuard loginAttemptGuard) + { + string? attemptedLogin = NormalizeLogin(body.Login); + + // Защита входа оператора (план Task 11, Ruling 5): зеркало AuthEndpoints — блокировка ключа + // ip|login до проверки учётных данных (в dev при RateLimit:Enabled=false гвард выключен). + if (await loginAttemptGuard.IsBlockedAsync(ClientIp(context), attemptedLogin, ct)) + { + return EndpointResults.TooManyRequests(LoginAttemptGuard.BlockedDetail); + } + + var result = await operatorAuthService.LoginAsync(body.Login, body.Password, ct); + if (result.Login is null || result.Token is null) + { + // Неверные учётные данные оператора — одно сообщение (зеркало AuthEndpoints). + // Аудит operator_login_failed — только для реальной попытки (непустой логин), без пароля (Ruling 4); + // счётчик неудач гварда растёт там же (пустые логины ключа не имеют). + if (attemptedLogin is not null) + { + await loginAttemptGuard.RecordFailureAsync(ClientIp(context), attemptedLogin, ct); + await auditService.AppendAsync(new AuditRecordDto( + AuditEvents.OperatorLoginFailed, + AuditActorTypes.Operator, + ActorId: null, + TenantId: null, + Ip: ClientIp(context), + DetailJson: AuditService.ToDetailJson(new { login = attemptedLogin })), ct); + } + + return EndpointResults.Unauthorized(InvalidCredentialsDetail); + } + + // Успешный вход оператора сбрасывает счётчик неудач ключа ip|login (Ruling 5). + await loginAttemptGuard.ResetAsync(ClientIp(context), result.Login, ct); + + await auditService.AppendAsync(new AuditRecordDto( + AuditEvents.OperatorLoginOk, + AuditActorTypes.Operator, + ActorId: result.OperatorId, + TenantId: null, + Ip: ClientIp(context), + DetailJson: AuditService.ToDetailJson(new { login = result.Login })), ct); + + SetOperatorSessionCookie(context, cookieOptions.Value, result.Token); + return Results.Ok(new { ok = true, login = result.Login }); + } + + // POST /api/operator/auth/logout: удаление операторской сессии по токену из куки и очистка куки (всегда ok). + private static async Task LogoutAsync( + OperatorAuthService operatorAuthService, + IOptions cookieOptions, + HttpContext context, + CancellationToken ct) + { + var cookieName = cookieOptions.Value.Name; + var rawToken = context.Request.Cookies[cookieName]; + // Оператор разрешённой сессии — до её удаления (OperatorSessionMiddleware наполнил Items). + CurrentOperator? operatorIdentity = context.GetCurrentOperator(); + await operatorAuthService.LogoutAsync(rawToken, ct); + context.Response.Cookies.Delete(cookieName); + + // Выход оператора (этап 10, T1): событие пишется при живой разрешённой сессии. + if (operatorIdentity is not null) + { + await AuditAppender.AppendOperatorAsync(context, AuditEvents.OperatorLogout, new { login = operatorIdentity.Login }, ct); + } + + return Results.Ok(new { ok = true }); + } + + // GET /api/operator/auth/me: проверка живой операторской сессии (401 без неё, Ruling 1). + private static IResult MeAsync(HttpContext context) + { + var operatorIdentity = context.GetCurrentOperator(); + if (operatorIdentity is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + return Results.Ok(new { login = operatorIdentity.Login, ok = true }); + } + + // Выставляет httpOnly-куку сессии оператора: SameSite=Lax, Path=/, MaxAge=Hours, Secure — из конфига. + // context: Контекст запроса. + // options: Настройки куки из конфигурации (секция OperatorCookies). + // rawToken: Raw-токен операторской сессии. + private static void SetOperatorSessionCookie(HttpContext context, OperatorCookieOptions options, string rawToken) + { + // MaxAge — OperatorCookies:Hours; код-дефолт значения ссылается на + // OperatorAuthService.SessionLifetimeHours (единый источник «12 часов», см. OperatorCookieOptions). + context.Response.Cookies.Append( + options.Name, + rawToken, + new AspNetCoreCookieOptions + { + HttpOnly = true, + SameSite = SameSiteMode.Lax, + Path = "/", + MaxAge = TimeSpan.FromHours(options.Hours), + Secure = options.Secure, + }); + } + + // Нормализованная попытка логина для аудита (нижний регистр/обрезка); null — писать нечего. + // login: Логин из тела запроса. + // Возвращает: Нормализованный логин или null при пустом/пробельном входе. + private static string? NormalizeLogin(string? login) + { + string? normalized = login?.Trim().ToLowerInvariant(); + return string.IsNullOrEmpty(normalized) ? null : normalized; + } + + // IP-адрес клиента для аудита (без порта; null, если недоступен). + // context: Контекст запроса. + // Возвращает: Строковое представление IP или null. + private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); +} diff --git a/src/core/Deal.Api/Endpoints/OperatorInvitesEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorInvitesEndpoints.cs index 1e548e9..5485a70 100644 --- a/src/core/Deal.Api/Endpoints/OperatorInvitesEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorInvitesEndpoints.cs @@ -1,6 +1,9 @@ using Deal.Api.Http; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Api.Endpoints; diff --git a/src/core/Deal.Api/Endpoints/OperatorLimitsEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorLimitsEndpoints.cs index 8d310dd..1464661 100644 --- a/src/core/Deal.Api/Endpoints/OperatorLimitsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorLimitsEndpoints.cs @@ -1,6 +1,9 @@ using Deal.Api.Http; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Api.Endpoints; diff --git a/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs index 02b0798..027fddf 100644 --- a/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorSettingsEndpoints.cs @@ -1,154 +1,157 @@ -using Deal.Api.Endpoints.RequestModels; -using Deal.Api.Http; -using Deal.Api.Telegram; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Api.Endpoints; - -/// -/// Операторские ручки глобальных (системных) настроек: ключи приложения Telegram -/// (ТЗ §4.1/§8.1). -/// -/// -/// Все ручки — только под операторской сессией: без неё 401 «Требуется вход оператора». Ключи Telegram -/// задаёт оператор глобально (едины для всех тенантов), тенант их не видит и не задаёт. -/// -/// GET /api/operator/settings/telegram-keys — маскированный снимок: apiId (не секрет, открыт), -/// apiHash (маска) и keysSet; -/// PUT /api/operator/settings/telegram-keys {apiId?, apiHash?} — частичное сохранение (можно -/// передать только одно поле, второе сохраняется); валидация (api_id 5..9 цифр, api_hash непустой), -/// шифрование секрета и аудит telegram_keys_changed (без секретов в деталях). -/// -/// Ошибки — 400/401 {detail} (формат прототипа, Ruling 10). -/// -public static class OperatorSettingsEndpoints -{ - // Префикс группы операторских настроек. - private const string SettingsGroupPrefix = "/api/operator/settings"; - - // OpenAPI-тег группы. - private const string SettingsOpenApiTag = "operator-settings"; - - // Относительный путь глобальных ключей Telegram (GET/PUT). - private const string TelegramKeysPath = "/telegram-keys"; - - // Текст 400: пустое тело PUT (ни одного поля). - private const string EmptyBodyDetail = "Укажите api_id и api_hash"; - - // Текст 400: частичное обновление, но ключей ещё нет — нужны оба поля. - private const string MissingKeysDetail = "Ключи ещё не заданы — укажите и api_id, и api_hash"; - - // Текст 400: api_id не 5..9 цифр. - private const string InvalidApiIdDetail = "api_id должен состоять из 5–9 цифр"; - - // Текст 400: api_hash пустой/маска/с префиксом enc:. - private const string InvalidApiHashDetail = "Укажите непустой api_hash"; - - /// - /// Регистрирует группу /api/operator/settings: telegram-keys (GET/PUT). - /// - /// Построитель маршрутов приложения. - /// Построитель маршрутов для цепочки вызовов. - public static IEndpointRouteBuilder MapOperatorSettingsEndpoints(this IEndpointRouteBuilder app) - { - var group = app.MapGroup(SettingsGroupPrefix).WithTags(SettingsOpenApiTag); - group.MapGet(TelegramKeysPath, GetTelegramKeysAsync); - group.MapPut(TelegramKeysPath, PutTelegramKeysAsync); - return app; - } - - // GET /api/operator/settings/telegram-keys: маскированные глобальные ключи Telegram. - // context: Контекст запроса. - // keys: Сервис глобальных ключей Telegram (scoped). - // ct: Токен отмены. - // Возвращает: 200 маскированный снимок или 401 без операторской сессии. - private static async Task GetTelegramKeysAsync( - HttpContext context, - TelegramKeysService keys, - CancellationToken ct) - { - if (context.GetCurrentOperator() is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct); - return Results.Ok(snapshot); - } - - // PUT /api/operator/settings/telegram-keys: частичное сохранение глобальных ключей Telegram - // оператором. - // Поля можно передавать по отдельности: непереданное поле (null) сохраняет текущее значение, - // явное значение (в т.ч. пустая строка) валидируется. Если ключей ещё нет, оба поля обязательны. - // body: Тело {apiId?, apiHash?} (хотя бы одно поле). - // context: Контекст запроса. - // keys: Сервис глобальных ключей Telegram (scoped). - // auditService: Сервис аудита (событие telegram_keys_changed). - // ct: Токен отмены. - // Возвращает: 200 маскированный снимок, 400 при невалидных/недостающих полях или 401 без операторской сессии. - private static async Task PutTelegramKeysAsync( - OperatorTelegramKeysRequest? body, - HttpContext context, - TelegramKeysService keys, - AuditService auditService, - CancellationToken ct) - { - var operatorIdentity = context.GetCurrentOperator(); - if (operatorIdentity is null) - { - return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); - } - - if (body is null) - { - return EndpointResults.BadRequest(EmptyBodyDetail); - } - - // null — поле не передано (сохраняем текущее); непустая строка/плейсхолдер — валидируем явно. - string? apiId = body.ApiId?.Trim(); - string? apiHash = body.ApiHash?.Trim(); - if (apiId is null && apiHash is null) - { - return EndpointResults.BadRequest(EmptyBodyDetail); - } - - if (apiId is not null && !TelegramKeysService.IsValidApiId(apiId)) - { - return EndpointResults.BadRequest(InvalidApiIdDetail); - } - - if (apiHash is not null && !TelegramKeysService.IsValidApiHash(apiHash)) - { - return EndpointResults.BadRequest(InvalidApiHashDetail); - } - - // Частичное обновление: недостающее поле берём из текущих ключей; если ключей ещё нет — нужны оба. - TgKeysSnapshot current = await keys.GetAsync(ct); - string effectiveApiId = apiId ?? current.ApiId; - string effectiveApiHash = apiHash ?? current.ApiHash; - if (effectiveApiId.Length == 0 || effectiveApiHash.Length == 0) - { - return EndpointResults.BadRequest(MissingKeysDetail); - } - - await keys.SaveAsync(effectiveApiId, effectiveApiHash, ct); - - // Аудит смены глобальных ключей: apiId — не секрет, apiHash в детали не пишется (Ruling 4). - await auditService.AppendAsync(new AuditRecordDto( - AuditEvents.TelegramKeysChanged, - AuditActorTypes.Operator, - ActorId: operatorIdentity.OperatorId, - TenantId: null, - Ip: ClientIp(context), - DetailJson: AuditService.ToDetailJson(new { apiId = effectiveApiId, apiHashSet = true })), ct); - - TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct); - return Results.Ok(snapshot); - } - - // IP-адрес клиента для аудита (без порта; null, если недоступен). - // context: Контекст запроса. - // Возвращает: Строковое представление IP или null. - private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); -} +using Deal.Api.Endpoints.RequestModels; +using Deal.Api.Http; +using Deal.Api.Telegram; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Api.Endpoints; + +/// +/// Операторские ручки глобальных (системных) настроек: ключи приложения Telegram +/// (ТЗ §4.1/§8.1). +/// +/// +/// Все ручки — только под операторской сессией: без неё 401 «Требуется вход оператора». Ключи Telegram +/// задаёт оператор глобально (едины для всех тенантов), тенант их не видит и не задаёт. +/// +/// GET /api/operator/settings/telegram-keys — маскированный снимок: apiId (не секрет, открыт), +/// apiHash (маска) и keysSet; +/// PUT /api/operator/settings/telegram-keys {apiId?, apiHash?} — частичное сохранение (можно +/// передать только одно поле, второе сохраняется); валидация (api_id 5..9 цифр, api_hash непустой), +/// шифрование секрета и аудит telegram_keys_changed (без секретов в деталях). +/// +/// Ошибки — 400/401 {detail} (формат прототипа, Ruling 10). +/// +public static class OperatorSettingsEndpoints +{ + // Префикс группы операторских настроек. + private const string SettingsGroupPrefix = "/api/operator/settings"; + + // OpenAPI-тег группы. + private const string SettingsOpenApiTag = "operator-settings"; + + // Относительный путь глобальных ключей Telegram (GET/PUT). + private const string TelegramKeysPath = "/telegram-keys"; + + // Текст 400: пустое тело PUT (ни одного поля). + private const string EmptyBodyDetail = "Укажите api_id и api_hash"; + + // Текст 400: частичное обновление, но ключей ещё нет — нужны оба поля. + private const string MissingKeysDetail = "Ключи ещё не заданы — укажите и api_id, и api_hash"; + + // Текст 400: api_id не 5..9 цифр. + private const string InvalidApiIdDetail = "api_id должен состоять из 5–9 цифр"; + + // Текст 400: api_hash пустой/маска/с префиксом enc:. + private const string InvalidApiHashDetail = "Укажите непустой api_hash"; + + /// + /// Регистрирует группу /api/operator/settings: telegram-keys (GET/PUT). + /// + /// Построитель маршрутов приложения. + /// Построитель маршрутов для цепочки вызовов. + public static IEndpointRouteBuilder MapOperatorSettingsEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup(SettingsGroupPrefix).WithTags(SettingsOpenApiTag); + group.MapGet(TelegramKeysPath, GetTelegramKeysAsync); + group.MapPut(TelegramKeysPath, PutTelegramKeysAsync); + return app; + } + + // GET /api/operator/settings/telegram-keys: маскированные глобальные ключи Telegram. + // context: Контекст запроса. + // keys: Сервис глобальных ключей Telegram (scoped). + // ct: Токен отмены. + // Возвращает: 200 маскированный снимок или 401 без операторской сессии. + private static async Task GetTelegramKeysAsync( + HttpContext context, + TelegramKeysService keys, + CancellationToken ct) + { + if (context.GetCurrentOperator() is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct); + return Results.Ok(snapshot); + } + + // PUT /api/operator/settings/telegram-keys: частичное сохранение глобальных ключей Telegram + // оператором. + // Поля можно передавать по отдельности: непереданное поле (null) сохраняет текущее значение, + // явное значение (в т.ч. пустая строка) валидируется. Если ключей ещё нет, оба поля обязательны. + // body: Тело {apiId?, apiHash?} (хотя бы одно поле). + // context: Контекст запроса. + // keys: Сервис глобальных ключей Telegram (scoped). + // auditService: Сервис аудита (событие telegram_keys_changed). + // ct: Токен отмены. + // Возвращает: 200 маскированный снимок, 400 при невалидных/недостающих полях или 401 без операторской сессии. + private static async Task PutTelegramKeysAsync( + OperatorTelegramKeysRequest? body, + HttpContext context, + TelegramKeysService keys, + AuditService auditService, + CancellationToken ct) + { + var operatorIdentity = context.GetCurrentOperator(); + if (operatorIdentity is null) + { + return EndpointResults.Unauthorized(AuthHelpers.OperatorUnauthorizedDetail); + } + + if (body is null) + { + return EndpointResults.BadRequest(EmptyBodyDetail); + } + + // null — поле не передано (сохраняем текущее); непустая строка/плейсхолдер — валидируем явно. + string? apiId = body.ApiId?.Trim(); + string? apiHash = body.ApiHash?.Trim(); + if (apiId is null && apiHash is null) + { + return EndpointResults.BadRequest(EmptyBodyDetail); + } + + if (apiId is not null && !TelegramKeysService.IsValidApiId(apiId)) + { + return EndpointResults.BadRequest(InvalidApiIdDetail); + } + + if (apiHash is not null && !TelegramKeysService.IsValidApiHash(apiHash)) + { + return EndpointResults.BadRequest(InvalidApiHashDetail); + } + + // Частичное обновление: недостающее поле берём из текущих ключей; если ключей ещё нет — нужны оба. + TgKeysSnapshot current = await keys.GetAsync(ct); + string effectiveApiId = apiId ?? current.ApiId; + string effectiveApiHash = apiHash ?? current.ApiHash; + if (effectiveApiId.Length == 0 || effectiveApiHash.Length == 0) + { + return EndpointResults.BadRequest(MissingKeysDetail); + } + + await keys.SaveAsync(effectiveApiId, effectiveApiHash, ct); + + // Аудит смены глобальных ключей: apiId — не секрет, apiHash в детали не пишется (Ruling 4). + await auditService.AppendAsync(new AuditRecordDto( + AuditEvents.TelegramKeysChanged, + AuditActorTypes.Operator, + ActorId: operatorIdentity.OperatorId, + TenantId: null, + Ip: ClientIp(context), + DetailJson: AuditService.ToDetailJson(new { apiId = effectiveApiId, apiHashSet = true })), ct); + + TelegramKeysMaskedDto snapshot = await keys.GetMaskedAsync(ct); + return Results.Ok(snapshot); + } + + // IP-адрес клиента для аудита (без порта; null, если недоступен). + // context: Контекст запроса. + // Возвращает: Строковое представление IP или null. + private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); +} diff --git a/src/core/Deal.Api/Endpoints/OperatorTenantsEndpoints.cs b/src/core/Deal.Api/Endpoints/OperatorTenantsEndpoints.cs index 43668b6..dd27c5f 100644 --- a/src/core/Deal.Api/Endpoints/OperatorTenantsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/OperatorTenantsEndpoints.cs @@ -1,6 +1,9 @@ using Deal.Api.Http; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Microsoft.Extensions.Options; using CookieOptions = Deal.Api.Configuration.CookieOptions; diff --git a/src/core/Deal.Api/Endpoints/PipelineEndpoints.cs b/src/core/Deal.Api/Endpoints/PipelineEndpoints.cs index e1191ae..4b40095 100644 --- a/src/core/Deal.Api/Endpoints/PipelineEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/PipelineEndpoints.cs @@ -1,7 +1,9 @@ using Deal.Api.Endpoints.RequestModels; using Deal.Api.Http; -using Deal.Modules.Pipeline.Application; +using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; namespace Deal.Api.Endpoints; diff --git a/src/core/Deal.Api/Endpoints/RatesEndpoints.cs b/src/core/Deal.Api/Endpoints/RatesEndpoints.cs index 7057aeb..b19bb5c 100644 --- a/src/core/Deal.Api/Endpoints/RatesEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/RatesEndpoints.cs @@ -1,6 +1,8 @@ using Deal.Api.Http; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Api.Endpoints; diff --git a/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs b/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs index f722173..e1b74f7 100644 --- a/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/SettingsEndpoints.cs @@ -1,9 +1,15 @@ using System.Text.Json; using Deal.Api; using Deal.Api.Http; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Api.Endpoints; diff --git a/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs b/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs index 78caaa3..645dcbd 100644 --- a/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs +++ b/src/core/Deal.Api/Endpoints/TelegramEndpoints.cs @@ -5,7 +5,11 @@ using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; using Deal.Modules.Telegram.Application; using Deal.Modules.Telegram.Application.Models; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Api.Endpoints; diff --git a/src/core/Deal.Api/Hosting/BudgetAlertScheduler.cs b/src/core/Deal.Api/Hosting/BudgetAlertScheduler.cs index 1a39e93..b2e56a7 100644 --- a/src/core/Deal.Api/Hosting/BudgetAlertScheduler.cs +++ b/src/core/Deal.Api/Hosting/BudgetAlertScheduler.cs @@ -1,6 +1,9 @@ using Deal.Api.Events; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Api.Hosting; diff --git a/src/core/Deal.Api/Hosting/DataRetentionScheduler.cs b/src/core/Deal.Api/Hosting/DataRetentionScheduler.cs index ded666c..fefab2d 100644 --- a/src/core/Deal.Api/Hosting/DataRetentionScheduler.cs +++ b/src/core/Deal.Api/Hosting/DataRetentionScheduler.cs @@ -1,5 +1,9 @@ using Deal.Api.Configuration; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Api.Hosting; diff --git a/src/core/Deal.Api/Hosting/DiscoveryWorkerScheduler.cs b/src/core/Deal.Api/Hosting/DiscoveryWorkerScheduler.cs index 99a5ea1..1561bb6 100644 --- a/src/core/Deal.Api/Hosting/DiscoveryWorkerScheduler.cs +++ b/src/core/Deal.Api/Hosting/DiscoveryWorkerScheduler.cs @@ -1,6 +1,14 @@ -using Deal.Modules.Discovery.Application; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants; namespace Deal.Api.Hosting; diff --git a/src/core/Deal.Api/Hosting/MlOutboxFlushScheduler.cs b/src/core/Deal.Api/Hosting/MlOutboxFlushScheduler.cs index e758695..a3e1d8a 100644 --- a/src/core/Deal.Api/Hosting/MlOutboxFlushScheduler.cs +++ b/src/core/Deal.Api/Hosting/MlOutboxFlushScheduler.cs @@ -1,8 +1,14 @@ using Deal.Infrastructure.Integrations; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants; namespace Deal.Api.Hosting; diff --git a/src/core/Deal.Api/Hosting/OperatorBootstrapHostedService.cs b/src/core/Deal.Api/Hosting/OperatorBootstrapHostedService.cs index a9c0b88..c168a21 100644 --- a/src/core/Deal.Api/Hosting/OperatorBootstrapHostedService.cs +++ b/src/core/Deal.Api/Hosting/OperatorBootstrapHostedService.cs @@ -1,4 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Api.Hosting; diff --git a/src/core/Deal.Api/Hosting/StorageTickScheduler.cs b/src/core/Deal.Api/Hosting/StorageTickScheduler.cs index 40beec3..2586bf5 100644 --- a/src/core/Deal.Api/Hosting/StorageTickScheduler.cs +++ b/src/core/Deal.Api/Hosting/StorageTickScheduler.cs @@ -1,9 +1,18 @@ using Deal.Api.Events; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants; namespace Deal.Api.Hosting; diff --git a/src/core/Deal.Api/Hosting/TenantBootstrapService.cs b/src/core/Deal.Api/Hosting/TenantBootstrapService.cs index f3570f7..da60f11 100644 --- a/src/core/Deal.Api/Hosting/TenantBootstrapService.cs +++ b/src/core/Deal.Api/Hosting/TenantBootstrapService.cs @@ -1,6 +1,9 @@ using Deal.Infrastructure.Tenancy; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Api.Hosting; diff --git a/src/core/Deal.Api/Http/AuditAppender.cs b/src/core/Deal.Api/Http/AuditAppender.cs index 068e86a..823e41d 100644 --- a/src/core/Deal.Api/Http/AuditAppender.cs +++ b/src/core/Deal.Api/Http/AuditAppender.cs @@ -1,83 +1,86 @@ -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; -using Microsoft.Extensions.DependencyInjection; - -namespace Deal.Api.Http; - -/// -/// Хелпер записи аудита действий пользователей тенанта и операторов (единая точка — ). -/// -/// -/// Актор берётся из разрешённой сессии (HttpContext.Items, наполняют SessionMiddleware/ -/// OperatorSessionMiddleware): для тенанта — с userId/tenantId, для -/// оператора — без tenantId. IP — адрес клиента без порта. -/// Детали — минимальные, без секретов (пароли/токены/api-ключи). Нет сессии — no-op (вызывать после -/// проверки HasUser, но безопасно и без неё). Append-only, как весь аудит. -/// -public static class AuditAppender -{ - /// - /// Пишет событие действия пользователя тенанта (актор tenant). - /// - /// Контекст запроса (источник актора и IP). - /// Тип события — константа . - /// Минимальные детали события (обычно анонимный объект) или null. - /// Токен отмены. - public static async Task AppendTenantAsync(HttpContext context, string eventType, object? details, CancellationToken ct) - { - CurrentUser? user = context.GetCurrentUser(); - if (user is null) - { - return; - } - - AuditService audit = context.RequestServices.GetRequiredService(); - await audit.AppendAsync( - new AuditRecordDto( - eventType, - AuditActorTypes.Tenant, - ActorId: user.UserId, - TenantId: user.TenantId, - Ip: ClientIp(context), - DetailJson: DetailJson(details)), - ct); - } - - /// - /// Пишет событие действия оператора (актор operator, без tenantId). - /// - /// Контекст запроса (источник актора и IP). - /// Тип события — константа . - /// Минимальные детали события (обычно анонимный объект) или null. - /// Токен отмены. - public static async Task AppendOperatorAsync(HttpContext context, string eventType, object? details, CancellationToken ct) - { - CurrentOperator? operatorIdentity = context.GetCurrentOperator(); - if (operatorIdentity is null) - { - return; - } - - AuditService audit = context.RequestServices.GetRequiredService(); - await audit.AppendAsync( - new AuditRecordDto( - eventType, - AuditActorTypes.Operator, - ActorId: operatorIdentity.OperatorId, - TenantId: null, - Ip: ClientIp(context), - DetailJson: DetailJson(details)), - ct); - } - - // Сериализует детали события (null — деталей нет). - // details: Объект деталей или null. - // Возвращает: JSON деталей (camelCase) или null. - private static string? DetailJson(object? details) => - details is null ? null : AuditService.ToDetailJson(details); - - // IP-адрес клиента для аудита (без порта; null, если недоступен). - // context: Контекст запроса. - // Возвращает: Строковое представление IP или null. - private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); -} +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Api.Http; + +/// +/// Хелпер записи аудита действий пользователей тенанта и операторов (единая точка — ). +/// +/// +/// Актор берётся из разрешённой сессии (HttpContext.Items, наполняют SessionMiddleware/ +/// OperatorSessionMiddleware): для тенанта — с userId/tenantId, для +/// оператора — без tenantId. IP — адрес клиента без порта. +/// Детали — минимальные, без секретов (пароли/токены/api-ключи). Нет сессии — no-op (вызывать после +/// проверки HasUser, но безопасно и без неё). Append-only, как весь аудит. +/// +public static class AuditAppender +{ + /// + /// Пишет событие действия пользователя тенанта (актор tenant). + /// + /// Контекст запроса (источник актора и IP). + /// Тип события — константа . + /// Минимальные детали события (обычно анонимный объект) или null. + /// Токен отмены. + public static async Task AppendTenantAsync(HttpContext context, string eventType, object? details, CancellationToken ct) + { + CurrentUser? user = context.GetCurrentUser(); + if (user is null) + { + return; + } + + AuditService audit = context.RequestServices.GetRequiredService(); + await audit.AppendAsync( + new AuditRecordDto( + eventType, + AuditActorTypes.Tenant, + ActorId: user.UserId, + TenantId: user.TenantId, + Ip: ClientIp(context), + DetailJson: DetailJson(details)), + ct); + } + + /// + /// Пишет событие действия оператора (актор operator, без tenantId). + /// + /// Контекст запроса (источник актора и IP). + /// Тип события — константа . + /// Минимальные детали события (обычно анонимный объект) или null. + /// Токен отмены. + public static async Task AppendOperatorAsync(HttpContext context, string eventType, object? details, CancellationToken ct) + { + CurrentOperator? operatorIdentity = context.GetCurrentOperator(); + if (operatorIdentity is null) + { + return; + } + + AuditService audit = context.RequestServices.GetRequiredService(); + await audit.AppendAsync( + new AuditRecordDto( + eventType, + AuditActorTypes.Operator, + ActorId: operatorIdentity.OperatorId, + TenantId: null, + Ip: ClientIp(context), + DetailJson: DetailJson(details)), + ct); + } + + // Сериализует детали события (null — деталей нет). + // details: Объект деталей или null. + // Возвращает: JSON деталей (camelCase) или null. + private static string? DetailJson(object? details) => + details is null ? null : AuditService.ToDetailJson(details); + + // IP-адрес клиента для аудита (без порта; null, если недоступен). + // context: Контекст запроса. + // Возвращает: Строковое представление IP или null. + private static string? ClientIp(HttpContext context) => context.Connection.RemoteIpAddress?.ToString(); +} diff --git a/src/core/Deal.Api/Http/LoginAttemptGuard.cs b/src/core/Deal.Api/Http/LoginAttemptGuard.cs index d805fac..b911766 100644 --- a/src/core/Deal.Api/Http/LoginAttemptGuard.cs +++ b/src/core/Deal.Api/Http/LoginAttemptGuard.cs @@ -1,5 +1,9 @@ using Deal.Api.Configuration; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Api.Http; diff --git a/src/core/Deal.Api/Http/StoreBackedFixedWindowRateLimiter.cs b/src/core/Deal.Api/Http/StoreBackedFixedWindowRateLimiter.cs index ac8dc43..6d95bb5 100644 --- a/src/core/Deal.Api/Http/StoreBackedFixedWindowRateLimiter.cs +++ b/src/core/Deal.Api/Http/StoreBackedFixedWindowRateLimiter.cs @@ -1,5 +1,9 @@ using System.Threading.RateLimiting; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Microsoft.Extensions.DependencyInjection; namespace Deal.Api.Http; diff --git a/src/core/Deal.Api/Middleware/OperatorSessionMiddleware.cs b/src/core/Deal.Api/Middleware/OperatorSessionMiddleware.cs index d85a770..9333706 100644 --- a/src/core/Deal.Api/Middleware/OperatorSessionMiddleware.cs +++ b/src/core/Deal.Api/Middleware/OperatorSessionMiddleware.cs @@ -1,6 +1,10 @@ using Deal.Api.Configuration; using Deal.Api.Http; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Microsoft.Extensions.Options; // Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом. using OperatorCookieOptions = Deal.Api.Configuration.OperatorCookieOptions; diff --git a/src/core/Deal.Api/Middleware/SessionMiddleware.cs b/src/core/Deal.Api/Middleware/SessionMiddleware.cs index 833ec86..4f8440d 100644 --- a/src/core/Deal.Api/Middleware/SessionMiddleware.cs +++ b/src/core/Deal.Api/Middleware/SessionMiddleware.cs @@ -1,6 +1,10 @@ using Deal.Api.Configuration; using Deal.Api.Http; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants; using Microsoft.Extensions.Options; // Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом. diff --git a/src/core/Deal.Api/Observability/RuntimeDepthsCollector.cs b/src/core/Deal.Api/Observability/RuntimeDepthsCollector.cs index 0f58c7d..3c18481 100644 --- a/src/core/Deal.Api/Observability/RuntimeDepthsCollector.cs +++ b/src/core/Deal.Api/Observability/RuntimeDepthsCollector.cs @@ -1,147 +1,156 @@ -using Deal.Infrastructure.Persistence; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Pipeline.Application; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Tenants; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Api.Observability; - -/// -/// Сборщик глубин очередей и активных сессий (этап 12, §10.2): единый источник для метрик и health. -/// -/// -/// -/// Обходит реестр тенантов (на тенант — вложенный scope с ITenantContext.SetTenant) и считает через -/// существующие сервисы/порты, без дублирования SQL: -/// -/// глубина очереди пайплайна — (new+filtered); -/// глубина очереди обучения ML — (count(MlOutbox)); -/// активные сессии — count(public.sessions) + count(public.operator_sessions) с непросроченным ExpiresAt. -/// -/// Переиспользуется фоновым (публикация в DealMetrics) и операторским -/// health (глубины прямо в JSON). Ошибки каждой секции логируются и не выбрасываются наружу (сбой тенанта не -/// валит проход; наружу летит только отмена); значения агрегируются по всем тенантам. -/// -/// -public sealed class RuntimeDepthsCollector -{ - private readonly IServiceScopeFactory _scopeFactory; - private readonly ILogger _logger; - - /// - /// Создаёт сборщик глубин. - /// - /// Фабрика scope: проход и тенант — в собственных scope. - /// Логгер ошибок секций. - public RuntimeDepthsCollector(IServiceScopeFactory scopeFactory, ILogger logger) - { - ArgumentNullException.ThrowIfNull(scopeFactory); - ArgumentNullException.ThrowIfNull(logger); - _scopeFactory = scopeFactory; - _logger = logger; - } - - /// - /// Собирает снимок глубин очередей и числа активных сессий (агрегат по всем тенантам). - /// - /// Токен отмены (пробрасывается в EF-запросы; отмена — единственное исключение наружу). - /// Снимок: суммарные глубины pipeline/ML-outbox и число активных сессий. - public async Task CollectAsync(CancellationToken ct) - { - await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope(); - int sessions = await CountActiveSessionsAsync(cycleScope, ct); - (long queue, long outbox) = await SumTenantDepthsAsync(cycleScope, ct); - return new RuntimeDepthsDto(queue, outbox, sessions); - } - - // Считает активные непросроченные сессии пользователей и операторов (public-схема). - // cycleScope: Scope прохода (DealDbContext — без tenant-контекста). - // ct: Токен отмены. - // Возвращает: Число активных сессий; сбой секции — 0 (снимок остаётся полезным). - private async Task CountActiveSessionsAsync(AsyncServiceScope cycleScope, CancellationToken ct) - { - try - { - DealDbContext dbContext = cycleScope.ServiceProvider.GetRequiredService(); - DateTimeOffset now = DateTimeOffset.UtcNow; - int tenantSessions = await dbContext.Sessions.CountAsync(session => session.ExpiresAt > now, ct); - int operatorSessions = await dbContext.OperatorSessions.CountAsync(session => session.ExpiresAt > now, ct); - return tenantSessions + operatorSessions; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - _logger.LogWarning(exception, "Сборщик глубин: подсчёт активных сессий не удался"); - return 0; - } - } - - // Суммирует глубины очередей по всем тенантам реестра. - // cycleScope: Scope прохода (реестр тенантов читается без tenant-контекста). - // ct: Токен отмены. - // Возвращает: Пара (сумма очереди пайплайна, сумма MlOutbox); недоступность реестра — (0, 0). - private async Task<(long Queue, long Outbox)> SumTenantDepthsAsync(AsyncServiceScope cycleScope, CancellationToken ct) - { - long queueDepth = 0; - long outboxDepth = 0; - try - { - ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService(); - IReadOnlyList tenants = await tenantRepository.ListAsync(ct); - foreach (TenantRecordDto tenant in tenants) - { - (int queue, int outbox) = await CollectTenantAsync(tenant, ct); - queueDepth += queue; - outboxDepth += outbox; - } - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - _logger.LogWarning(exception, "Сборщик глубин: обход реестра тенантов не удался"); - } - - return (queueDepth, outboxDepth); - } - - // Считает глубины очередей одного тенанта в собственном scope (SetTenant → сервисы → Reset). - // tenant: Тенант реестра (Id в формате Guid; схема — tenant_<N>). - // ct: Токен отмены прохода. - // Возвращает: Пара (глубина очереди пайплайна, глубина MlOutbox) для тенанта; сбой — (0, 0). - private async Task<(int Queue, int Outbox)> CollectTenantAsync(TenantRecordDto tenant, CancellationToken ct) - { - await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope(); - ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); - try - { - tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); - PipelineProcessingService processing = tenantScope.ServiceProvider.GetRequiredService(); - QueueCountsDto counts = await processing.QueueCountsAsync(ct); - IMlLearningStore learningStore = tenantScope.ServiceProvider.GetRequiredService(); - int outbox = await learningStore.CountOutboxAsync(ct); - return (counts.Total, outbox); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - _logger.LogWarning(exception, "Сборщик глубин: подсчёт очередей тенанта {TenantId} не удался", tenant.Id); - return (0, 0); - } - finally - { - tenantContext.Reset(); - } - } -} +using Deal.Infrastructure.Persistence; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; +using Deal.SharedKernel.Tenants; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Api.Observability; + +/// +/// Сборщик глубин очередей и активных сессий (этап 12, §10.2): единый источник для метрик и health. +/// +/// +/// +/// Обходит реестр тенантов (на тенант — вложенный scope с ITenantContext.SetTenant) и считает через +/// существующие сервисы/порты, без дублирования SQL: +/// +/// глубина очереди пайплайна — (new+filtered); +/// глубина очереди обучения ML — (count(MlOutbox)); +/// активные сессии — count(public.sessions) + count(public.operator_sessions) с непросроченным ExpiresAt. +/// +/// Переиспользуется фоновым (публикация в DealMetrics) и операторским +/// health (глубины прямо в JSON). Ошибки каждой секции логируются и не выбрасываются наружу (сбой тенанта не +/// валит проход; наружу летит только отмена); значения агрегируются по всем тенантам. +/// +/// +public sealed class RuntimeDepthsCollector +{ + private readonly IServiceScopeFactory _scopeFactory; + private readonly ILogger _logger; + + /// + /// Создаёт сборщик глубин. + /// + /// Фабрика scope: проход и тенант — в собственных scope. + /// Логгер ошибок секций. + public RuntimeDepthsCollector(IServiceScopeFactory scopeFactory, ILogger logger) + { + ArgumentNullException.ThrowIfNull(scopeFactory); + ArgumentNullException.ThrowIfNull(logger); + _scopeFactory = scopeFactory; + _logger = logger; + } + + /// + /// Собирает снимок глубин очередей и числа активных сессий (агрегат по всем тенантам). + /// + /// Токен отмены (пробрасывается в EF-запросы; отмена — единственное исключение наружу). + /// Снимок: суммарные глубины pipeline/ML-outbox и число активных сессий. + public async Task CollectAsync(CancellationToken ct) + { + await using AsyncServiceScope cycleScope = _scopeFactory.CreateAsyncScope(); + int sessions = await CountActiveSessionsAsync(cycleScope, ct); + (long queue, long outbox) = await SumTenantDepthsAsync(cycleScope, ct); + return new RuntimeDepthsDto(queue, outbox, sessions); + } + + // Считает активные непросроченные сессии пользователей и операторов (public-схема). + // cycleScope: Scope прохода (DealDbContext — без tenant-контекста). + // ct: Токен отмены. + // Возвращает: Число активных сессий; сбой секции — 0 (снимок остаётся полезным). + private async Task CountActiveSessionsAsync(AsyncServiceScope cycleScope, CancellationToken ct) + { + try + { + DealDbContext dbContext = cycleScope.ServiceProvider.GetRequiredService(); + DateTimeOffset now = DateTimeOffset.UtcNow; + int tenantSessions = await dbContext.Sessions.CountAsync(session => session.ExpiresAt > now, ct); + int operatorSessions = await dbContext.OperatorSessions.CountAsync(session => session.ExpiresAt > now, ct); + return tenantSessions + operatorSessions; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Сборщик глубин: подсчёт активных сессий не удался"); + return 0; + } + } + + // Суммирует глубины очередей по всем тенантам реестра. + // cycleScope: Scope прохода (реестр тенантов читается без tenant-контекста). + // ct: Токен отмены. + // Возвращает: Пара (сумма очереди пайплайна, сумма MlOutbox); недоступность реестра — (0, 0). + private async Task<(long Queue, long Outbox)> SumTenantDepthsAsync(AsyncServiceScope cycleScope, CancellationToken ct) + { + long queueDepth = 0; + long outboxDepth = 0; + try + { + ITenantRepository tenantRepository = cycleScope.ServiceProvider.GetRequiredService(); + IReadOnlyList tenants = await tenantRepository.ListAsync(ct); + foreach (TenantRecordDto tenant in tenants) + { + (int queue, int outbox) = await CollectTenantAsync(tenant, ct); + queueDepth += queue; + outboxDepth += outbox; + } + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Сборщик глубин: обход реестра тенантов не удался"); + } + + return (queueDepth, outboxDepth); + } + + // Считает глубины очередей одного тенанта в собственном scope (SetTenant → сервисы → Reset). + // tenant: Тенант реестра (Id в формате Guid; схема — tenant_<N>). + // ct: Токен отмены прохода. + // Возвращает: Пара (глубина очереди пайплайна, глубина MlOutbox) для тенанта; сбой — (0, 0). + private async Task<(int Queue, int Outbox)> CollectTenantAsync(TenantRecordDto tenant, CancellationToken ct) + { + await using AsyncServiceScope tenantScope = _scopeFactory.CreateAsyncScope(); + ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); + try + { + tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); + PipelineProcessingService processing = tenantScope.ServiceProvider.GetRequiredService(); + QueueCountsDto counts = await processing.QueueCountsAsync(ct); + IMlLearningStore learningStore = tenantScope.ServiceProvider.GetRequiredService(); + int outbox = await learningStore.CountOutboxAsync(ct); + return (counts.Total, outbox); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + _logger.LogWarning(exception, "Сборщик глубин: подсчёт очередей тенанта {TenantId} не удался", tenant.Id); + return (0, 0); + } + finally + { + tenantContext.Reset(); + } + } +} diff --git a/src/core/Deal.Api/PipelineWorkerScheduler.cs b/src/core/Deal.Api/PipelineWorkerScheduler.cs index f1f43fb..fc688c2 100644 --- a/src/core/Deal.Api/PipelineWorkerScheduler.cs +++ b/src/core/Deal.Api/PipelineWorkerScheduler.cs @@ -1,9 +1,14 @@ using Deal.Api.Events; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application; +using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants; namespace Deal.Api; diff --git a/src/core/Deal.Api/Program.cs b/src/core/Deal.Api/Program.cs index 524a112..8816afd 100644 --- a/src/core/Deal.Api/Program.cs +++ b/src/core/Deal.Api/Program.cs @@ -1,742 +1,760 @@ -using System.Net; -using System.Net.Sockets; -using System.Text.Encodings.Web; -using Deal.Api; -using Deal.Api.Configuration; -using Deal.Api.Endpoints; -using Deal.Api.Events; -using Deal.Api.Hosting; -using Deal.Api.Http; -using Deal.Api.Logging; -using Deal.Api.Middleware; -using Deal.Api.Observability; -using Deal.Api.Telegram; -using Deal.Contracts.Integrations; -using Deal.Infrastructure; -using Deal.Infrastructure.Data; -using Deal.Infrastructure.Integrations; -using Deal.Infrastructure.Integrations.Storage; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Services; -using Deal.Modules.Discovery.Application; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Pipeline.Application; -using Deal.Modules.Settings.Application; -using Deal.Modules.Telegram.Application; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Tenants; -using Microsoft.AspNetCore.HttpOverrides; -using Microsoft.AspNetCore.Server.Kestrel.Core; -using Microsoft.AspNetCore.Server.Kestrel.Https; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.Diagnostics.HealthChecks; -// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом. -using CookieOptions = Deal.Api.Configuration.CookieOptions; - -const string cookiesSectionName = "Cookies"; -// Секция настроек куки оператора (Ruling 1): имя deal_operator_session, срок 12 ч, Secure из конфига. -const string operatorCookiesSectionName = "OperatorCookies"; -// Имя CORS-политики (план Task 12, Ruling 10): одна политика, режим зависит от Security:AllowedOrigins. -const string corsPolicyName = "cors"; -// Секции конфигурации интеграций (Ruling 6): Services:Ml / Services:Ai / Services:Telegram → {UseLocal, Endpoint}. -const string servicesSectionName = "Services:Ml"; -const string aiServicesSectionName = "Services:Ai"; -const string telegramServicesSectionName = "Services:Telegram"; -// Имя истории tenant-миграций без схемы (схема — через search_path; миграции применяет -// TenantProvisioningService на старте, runtime-контекст их не выполняет). -const string tenantMigrationsHistoryTable = "__TenantMigrationsHistory"; -// Порт gRPC-ингресса по умолчанию (Ruling 7): :5082; переопределяется env GRPC_INGRESS_PORT. -const int defaultIngressPort = 5082; -const string ingressPortEnvKey = "GRPC_INGRESS_PORT"; -// Ключ конфигурации адресов основного HTTP-эндпоинта (--urls/ASPNETCORE_URLS/launchSettings). -const string serverUrlsKey = "urls"; -// Фолбэк основного HTTP-адреса при отсутствии явных URL (дефолт ASP.NET Core http://localhost:5000). -const string defaultHttpUrl = "http://localhost:5000"; -// Ключ env-переопределения дефолт-бюджета нового тенанта (Ruling 3; токенов в месяц, период — всегда month). -const string defaultAiBudgetEnvKey = "DEAL_DEFAULT_AI_BUDGET"; -// Секция настроек rate limiting (план Task 11, Ruling 5): RateLimit:Enabled=false в dev/тестах по -// умолчанию (curl-приёмки не режутся); PROD включает env-переопределением RateLimit__Enabled=true -// (compose-prod, Task 14). -const string rateLimitSectionName = "RateLimit"; -// Секция авто-очистки данных (этап 12, пакет B): DataRetention:AuditRetentionDays (дефолт 180) + Enabled — -// фоновый цикл DataRetentionScheduler чистит audit_log по retention, лимиты/счётчики прошедших окон. -const string dataRetentionSectionName = "DataRetention"; -// Секция настроек безопасности HTTP (план Task 12, Ruling 10): Security:AllowedOrigins — явный allowlist -// Origin-проверки/CORS (пусто — dev-режим «свой origin» + CORS-любой; PROD — домены фронта, Ruling 9). -const string securitySectionName = "Security"; -// Секция доверия прокси-заголовкам (план Task 12; замечание ревью T4/T11): ForwardedHeaders:KnownProxies/ -// KnownNetworks — доверенные прокси (Caddy в PROD); dev-дефолт в appsettings — loopback. -const string forwardedHeadersSectionName = "ForwardedHeaders"; - -// Имя процесса для rolling-файла логов (Ruling 7, Task 14): data/logs/deal-core-<дата>.json. -const string coreProcessName = "core"; - -var builder = WebApplication.CreateBuilder(args); - -// Структурированные логи Serilog (Ruling 7, план Task 14): консоль JSON в prod / текст в dev + -// rolling-файл data/logs/deal-core-*.json (data — volume контейнера). Уровень/каталог — env -// DEAL_LOG_LEVEL/DEAL_LOGS_DIR (см. DealLogging). Регистрируется до остальных сервисов: логирование -// заменяет провайдеры Microsoft при builder.Build(). -DealLogging.Configure(builder, coreProcessName); - -// Метрики OpenTelemetry → Prometheus (этап 12, пакет A): эндпоинт /metrics в формате Prometheus на -// отдельном HTTP/1.1 Kestrel-эндпоинте (порт 9464/env METRICS_PORT) + инструментация входящих HTTP- -// запросов и исходящих HTTP-клиентов; прикладные метрики — SharedKernel/Observability/DealMetrics. -int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort); -DealMetricsHosting.AddDealMetrics(builder, metricsPort); - -// Строка подключения Postgres — ТОЛЬКО из конфигурации (env/appsettings): dev-пароль в коде отсутствует -// (Security review). Отсутствие строки = fail-fast на старте, а не тихий уход на несуществующую dev-БД. -var connectionString = builder.Configuration.GetConnectionString("DealPostgres") - ?? throw new InvalidOperationException("ConnectionStrings:DealPostgres не задан"); -builder.Services.AddDbContext(options => options.UseNpgsql(connectionString)); -builder.Services.AddSingleton(); -builder.Services.AddSingleton(); - -// mTLS внутреннего gRPC-транспорта (Ruling 6, план Task 13): env DEAL_MTLS_* — флаг и пути/пароли -// сертификатов (только env, Ruling 13). Dev-дефолт — выключено: сервисы и ингресс остаются на plaintext + -// service-token (Ruling 2 этапа 6); compose-prod (Task 14) передаёт env и монтирует deploy/certs -// (генерация — scripts/mtls-certs.sh). При DEAL_MTLS_ENABLED=1 сертификаты грузятся сразу (fail-fast на -// битые пути/пароли) — один экземпляр используют и Kestrel-ингресс ниже, и gRPC-клиенты -// (Ml/Ai/Telegram-каналы + ServiceHealthProbe). -MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(builder.Configuration); -MtlsCertificates? mtlsCertificates = MtlsCertificates.Load(mtlsOptions); -if (mtlsCertificates is not null) -{ - builder.Services.AddSingleton(mtlsCertificates); -} - -// Kestrel: основной HTTP/1.1-эндпоинт из URL-конфигурации (как раньше — --urls/ASPNETCORE_URLS/ -// launchSettings) + второй endpoint gRPC-ингресса telegram-service (:5082, HTTP/2, env GRPC_INGRESS_PORT; -// план Task 12, Ruling 7). Явные Listen заменяют URL-биндинг Kestrel, поэтому основной эндпоинт -// пере-биндим адресами конфигурации "urls" явно (см. BindMainHttpEndpoints ниже). Ingress слушает все -// интерфейсы (AnyIP): в dev к нему ходит telegram-service из compose-сети через host.docker.internal (Ruling 12). -builder.WebHost.ConfigureKestrel(kestrel => -{ - BindMainHttpEndpoints(kestrel, builder.Configuration[serverUrlsKey]); - int ingressPort = ParsePort(builder.Configuration[ingressPortEnvKey]) ?? defaultIngressPort; - kestrel.ListenAnyIP(ingressPort, listen => - { - listen.Protocols = HttpProtocols.Http2; - if (mtlsCertificates is not null) - { - // mTLS-ингресс (Ruling 6, Task 13): HTTPS с серверным сертификатом core + обязательный клиентский - // сертификат (цепочка до CA из DEAL_MTLS_CA_PEM). Основной HTTP :5080 остаётся http — TLS наружу - // терминирует Caddy (Ruling 9, compose-prod Task 14). - listen.UseHttps(https => - { - https.ServerCertificate = mtlsCertificates.ServerCertificate; - https.ClientCertificateMode = ClientCertificateMode.RequireCertificate; - https.ClientCertificateValidation = mtlsCertificates.ValidateClientCertificate; - }); - } - }); -}); - -// TenantDbContext — scoped-контекст бессхемной модели тенанта (таблица settings и др.): строка -// подключения на каждый scope строится по текущему ITenantContext (заполняет SessionMiddleware) -// с Search Path на схему тенанта (ConnectionStringProvider.ForTenant). Опции живут в scope запроса -// (optionsLifetime: Scoped) — иначе опции с первым тенантом закешировались бы в singleton. -// Безопасность: вне tenant-запроса (нет сессии) контекст не имеет смысла — ошибка конфигурации. -builder.Services.AddDbContext( - (serviceProvider, options) => - { - var tenantContext = serviceProvider.GetRequiredService(); - if (!tenantContext.HasTenant) - { - throw new InvalidOperationException( - "TenantDbContext запрошен вне tenant-запроса: на запрос не разрешена сессия " - + "(ITenantContext.HasTenant == false)."); - } - - var connectionStringProvider = serviceProvider.GetRequiredService(); - options.UseNpgsql( - connectionStringProvider.ForTenant(tenantContext.TenantId), - npgsql => npgsql.MigrationsHistoryTable(tenantMigrationsHistoryTable)); - }, - contextLifetime: ServiceLifetime.Scoped, - optionsLifetime: ServiceLifetime.Scoped); - -// Модуль Tenants и его EF-адаптеры («port & adapter», Ruling 1). -builder.Services.AddTenantsModule(); - -// Лимиты ИИ-бюджета (Ruling 3, Task 8): дефолт-бюджет лениво создаваемой строки public.tenant_limits — -// env DEAL_DEFAULT_AI_BUDGET (токенов в месяц) с фолбэком на константу модуля TokenBudgetDefaults (10 000 000); -// период нового тенанта — month (константа). Значение читается один раз на старте и передаётся адаптеру -// TenantLimitStore (GetOrCreateAsync при первом чтении/списании, задачи 7/10 list-путь тоже закрыт). -TokenLimitDefaults tenantLimitDefaults = new( - ResolveDefaultAiBudget(builder.Configuration), TokenBudgetDefaults.DefaultPeriod); -builder.Services.AddDealPersistence(tenantLimitDefaults); - -// Шифрование секретов (Ruling 2): ISecretCipher — AES-256-GCM; ключ из DEAL_ENCRYPTION_KEY -// либо файла data/encryption.key под ContentRoot (dev). Ключ разрешается на старте — -// невалидный env-ключ останавливает запуск. -builder.Services.AddDealSecurity(builder.Environment.ContentRootPath); - -// Внешние интеграции (Tasks 9/16/15, Rulings 4/5/6/9): IMlClient — детерминированная заглушка LocalMlClient -// (Services:Ml:UseLocal=true, default; обучение — этап 3) либо gRPC-клиент GrpcMlClient (UseLocal=false, -// ml-service :5103). Local-адаптеры читают KV-настройки тенанта через ISettingsStore — scoped (вне -// tenant-запроса не разрешимы). IColumnSuggester — LocalColumnSuggester (эвристика, Self-Review L525–527). -MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get() ?? new MlServiceOptions(); -builder.Services.AddSingleton(mlOptions); -// AI-интеграция (план Task 15, Ruling 6/9): IAiClassifier/IAiTools — Local-адаптеры (Services:Ai:UseLocal=true, -// default; локальный разбор ядра / инструменты не поддерживаются) либо декораторы бюджетного гейта поверх -// gRPC-клиентов ai-service (UseLocal=false, ai-service :5102; AddDealIntegrations регистрирует GrpcAiClassifier/ -// GrpcAiTools + транспорт AiGrpcConnection — fail-fast, как MlGrpcConnection — и оборачивает их в -// BudgetedAiClassifier/BudgetedAiTools, Ruling 3/Task 9). -AiServiceOptions aiOptions = builder.Configuration.GetSection(aiServicesSectionName).Get() ?? new AiServiceOptions(); -builder.Services.AddSingleton(aiOptions); -// Telegram-гейт (план Task 14, Ruling 6/7): LocalTelegramGateway (Services:Telegram:UseLocal=true, default — -// нейтральный no-op/idle) либо gRPC-клиент GrpcTelegramClient (UseLocal=false, telegram-service :5101). -TelegramServiceOptions telegramOptions = builder.Configuration.GetSection(telegramServicesSectionName).Get() ?? new TelegramServiceOptions(); -builder.Services.AddSingleton(telegramOptions); -builder.Services.AddDealIntegrations(mlOptions, aiOptions, telegramOptions, mtlsCertificates); - -// Health-проба автономных сервисов для операторского health (план Task 10, Ruling 3/6/9): grpc.health.v1 -// к Services:*:Endpoint с дедлайном 3 с (ServiceHealthProbe). Stateless, singleton — пробы строят -// короткоживущие каналы на каждый вызов (mTLS-каналы — при включённом флаге, Task 13/Ruling 6). -builder.Services.AddSingleton(new ServiceHealthProbe(mtlsCertificates)); - -// Файловое хранилище вложений проектных карточек (Ruling 4, Task 6): LocalFileStorage (data/attachments -// под ContentRoot) — dev/curl/unit по умолчанию; MinioFileStorage регистрируется, только когда сконфигурирован -// MinIO (секция Storage:Minio либо env-алиасы DEAL_MINIO_*; compose-сервис deal-minio, порты 9000/9001). -// Singleton: хранилище не привязано к схеме тенанта (объекты — в едином бакете/каталоге, мульти-аренда -// объектного хранилища — этап 7 SaaS). Режим логируется на старте (см. ниже) — приёмка Task 6. -builder.Services.AddDealFileStorage(builder.Configuration, builder.Environment.ContentRootPath); - -// Модуль Settings (сервис настроек тенанта); адаптеры ISettingsStore/ISecretCipher уже -// зарегистрированы AddDealPersistence/AddDealSecurity выше (см. Task 4). -builder.Services.AddSettingsModule(); - -// Модуль Kanban — единый домен карточки (Ruling 12): регистратор сервисов карточек/контейнеров/тиков; -// порт-адаптер ICardStore → KanbanStore уже зарегистрирован AddDealPersistence. -builder.Services.AddKanbanModule(); - -// Модуль Pipeline (Ruling 10, Task 9): приём/обработка/воркер и ядра разбора этапа 4. Порт-адаптер -// IPipelineStore → PipelineStore и внешние порты (IMlClient/IAiClassifier) уже зарегистрированы -// AddDealPersistence/AddDealIntegrations выше; сервисы модуля вызывают из эндпоинтов /api/pipeline/* -// и gRPC-ингресса telegram-service, pump — admin/tick (Task 10) и фоновый цикл (Task 11). -builder.Services.AddPipelineModule(); - -// Модуль Telegram (план Task 13, Ruling 7): сервис каталога диалогов (DialogsService) — владелец таблиц -// Dialogs/TgMessages схемы тенанта (миграция TenantTelegram). Порт-адаптеры ITelegramStore → TelegramStore и -// ITelegramGateway → LocalTelegramGateway/GrpcTelegramClient зарегистрированы AddDealPersistence/AddDealIntegrations -// выше; сервис зовут gRPC-ингресс (SyncDialogs/PushMessage) и эндпоинты /api/tg (Task 14). -builder.Services.AddTelegramModule(); - -// Модуль Discovery (план Task 17/18, Ruling 9/10): сервисы задач/кандидатов/чёрного списка/лога, план-бюджет -// и воркер (оценка/бан-гард/паузы). Порт-адаптер IDiscoveryStore → DiscoveryStore зарегистрирован -// AddDealPersistence; внешние порты (ITelegramGateway/IAiTools/IMlClient) — AddDealIntegrations выше. Эндпоинты -// /api/discovery добавляет Task 19; фоновый цикл воркера — DiscoveryWorkerScheduler ниже. -builder.Services.AddDiscoveryModule(); - -// Статус/ключи вкладки Telegram (план Task 14, Ruling 8): сборка GET /api/tg/status (гейт + KV tgAccount + -// счётчик мониторящихся + keysSet) и чтение глобальных ключей приложения (telegramKeys в public.global_settings, -// расшифровка apiHash — задаёт оператор, ТЗ §4.1/§8.1). Scoped: зависимости — ISettingsStore/ITelegramStore -// на TenantDbContext схемы тенанта запроса, IGlobalSettingsStore — на системном DealDbContext. -builder.Services.AddScoped(); -builder.Services.AddScoped(); - -// Фоновые спуски перечитывания каналов (план Task 14, Ruling 8; аналог python-_spawn роутеров): эндпоинты -// мониторинга/«Перечитать» отвечают сразу, тяжёлый разбор идёт в отдельном scope с захваченным tenant-контекстом. -builder.Services.AddSingleton(); - -// Оркестратор ручного тика (план Task 10, Rulings 8/9): тик правил хранения (Kanban) + очистка отсева -// Pipeline + один проход pump + SSE-публикации (тосты/new_card) для POST /api/admin/tick (StorageEndpoints). -// Scoped: зависимости живут в рамках tenant-запроса (scoped-сервисы модулей на TenantDbContext схемы). -builder.Services.AddScoped(); - -// Обслуживание FTS-индексов схемы тенанта (Ruling 6, план Task 10): POST /api/admin/fts/rebuild — -// CREATE INDEX IF NOT EXISTS + ANALYZE (FtsMaintenance) на TenantDbContext запроса (scoped, как адаптеры). -builder.Services.AddScoped(); - -// SSE-брокер этапа (Ruling 5): singleton per-tenant каналов событий; подписка — GET /api/events, -// публикации — из эндпоинтов Api после вызова сервисов (Tasks 10/13/14). -builder.Services.AddSingleton(); - -// SSE-тосты статистики тика правил хранения (Ruling 8): единый хелпер для POST /api/admin/tick -// (StorageEndpoints) и фонового StorageTickScheduler (Task 11) — без дублирования текстов/иконок. -builder.Services.AddSingleton(); - -// Общий воркер-гейт pump тенанта (план Task 11, Ruling 8; аналог asyncio.Lock pipeline.py L38–42): -// POST /api/admin/tick (AdminTickOrchestrator) и фоновый цикл разбора очереди (PipelineWorkerScheduler) -// не разбирают очередь одного тенанта одновременно (singleton per-tenant флагов, Interlocked). -builder.Services.AddSingleton(); - -// Rate limiting и защита входа (план Task 11, Ruling 5; этап 12, пакет B — хранилище на Postgres): секция -// "RateLimit" (appsettings.json + env RateLimit__*). Enabled=false в dev/тестах — политики/middleware/ -// интерцептор не регистрируются вовсе (Ruling 5: «в dev выключено — curl-приёмки не режутся»); -// LoginAttemptGuard (окно ip|login 5 неудач/15 мин в public.rate_limit_counters) регистрируется всегда, -// но активен только при Enabled. -RateLimitOptions rateLimitOptions = builder.Configuration - .GetSection(rateLimitSectionName) - .Get() ?? new RateLimitOptions(); -builder.Services.AddSingleton(rateLimitOptions); -// Гвард попыток входа — scoped: его хранилище счётчиков (IRateLimitCounterStore) — scoped EF-адаптер -// (public.rate_limit_counters). Активен только при Enabled (no-op иначе). -builder.Services.AddScoped(); -if (rateLimitOptions.Enabled) -{ - builder.Services.AddDealRateLimiter(rateLimitOptions); -} - -// gRPC-ингресс telegram-service (план Task 12, Ruling 1/7): сервер Deal.Grpc.Telegram.IngressService -// на отдельном Kestrel-endpoint (:5082, HTTP/2, см. ConfigureKestrel выше) в том же процессе. Token -// из metadata «service-token» проверяет интерцептор (fail-closed, DEAL_SERVICE_TOKEN); AddAuthentication -// не нужен — пользовательская сессия HTTP ингрессом не используется (tenant-id из metadata → SetTenant). -builder.Services.AddGrpc(grpc => -{ - // Access-лог RPC ингресса (Ruling 7, Task 14): ПЕРВЫМ в цепочке — логируются и отклонённые - // вызовы (401/429); gRPC-health не логируется (см. RpcCallLoggingInterceptor). - grpc.Interceptors.Add(); - grpc.Interceptors.Add(); - if (rateLimitOptions.Enabled) - { - // Лимит входящего потока по tenant-id (план Task 11, Ruling 5): окно считает общий - // singleton-лимитер (CreateLimiter) — экземпляры интерцептора общий PartitionedRateLimiter - // разделяют; health-методы освобождены (см. IngressRateLimitInterceptor). - grpc.Interceptors.Add(); - } -}); -if (rateLimitOptions.Enabled) -{ - builder.Services.AddSingleton(provider => - IngressRateLimitInterceptor.CreateLimiter( - provider.GetRequiredService(), - rateLimitOptions.GrpcIngressPerMinute)); -} - -builder.Services.AddScoped(); - -// gRPC-health ингресса (план Task 20, Ruling 12): healthcheck контейнера core в docker compose. -// grpc.health.v1.Health интерцептор токеном не проверяет (инфраструктурный liveness, как в сервисах -// этапа T2–T4); регистрируется явная проверка "ready" — без неё health-сервис отвечает UNKNOWN. -// Живучесть интеграций (ml/ai/telegram) health не проверяет — недоступность сервиса это UNAVAILABLE -// на RPC и фолбэк Local-адаптеров, а не падение хоста (Ruling 6). -builder.Services - .AddGrpcHealthChecks() - .AddCheck("ready", () => HealthCheckResult.Healthy("хост Deal.Api готов")); - -// Проверка подключения AI-провайдера (Task 6, Ruling 7): порт модуля IAiConnectionChecker → -// HTTP-адаптер Infrastructure с собственным HttpClient (фабрика AddHttpClient, таймаут 12 с). -// HTTP наружу ходит только по действию Settings-экрана (POST /api/ai/check) — GET {base}/models. -builder.Services.AddHttpClient( - client => client.Timeout = TimeSpan.FromSeconds(AiConnectionChecker.RequestTimeoutSeconds)); - -// Курсы валют (Task 8, Ruling 6): порт модуля IRatesSource → HTTP-адаптер ЦБ с собственным -// HttpClient (таймаут 15 с, как httpx timeout=15 в rates.py). URL — фиксированная константа -// адаптера (SSRF-allowlist), источник тенантом не настраивается. Типизированный клиент -// регистрируется transient и живёт в рамках scope запроса (как IAiConnectionChecker, Task 6). -builder.Services.AddHttpClient( - client => client.Timeout = TimeSpan.FromSeconds(CbrRateSource.RequestTimeoutSeconds)); - -// Фоновое обновление кэша курсов вне запроса (Ruling 6): PATCH rateSource и лениво на GET — -// собственный scope + in-flight guard (см. RatesRefreshScheduler). -builder.Services.AddSingleton(); - -// Bootstrap при старте (Ruling 8): дефолтный тенант + admin, провижининг схем всех тенантов. -builder.Services.AddHostedService(); - -// Bootstrap оператора при старте (Ruling 1 этапа 7): env DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD, -// dev-дефолт operator/operator в Development; в Production без env — warning и пропуск. Идёт после -// TenantBootstrapService: операторские public-таблицы не зависят от провижининга схем тенантов. -builder.Services.AddHostedService(); - -// Фоновый цикл правил хранения (план Task 11, Ruling 8; аналог _storage_loop main.py L43–53): каждые -// 30 с тикает ВСЕ тенанты (StorageTickService + автоочистка отсева пайплайна 3 суток) и публикует -// SSE-тосты. Регистрируется после Bootstrap — первый проход стартует уже после провижининга схем. -builder.Services.AddHostedService(); - -// Фоновый цикл SSE-алертов ИИ-бюджета (план Task 9, Ruling 3; эталон StorageTickScheduler): каждые 60 с -// проверяет ВСЕ тенанты и публикует в канал тенанта тост при пересечении порогов 80/100% (TryMark*-CAS — -// один тост на порог за период). Идёт после Bootstrap: реестр тенантов провижинен до первого прохода. -builder.Services.AddHostedService(); - -// Фоновый цикл разбора очереди входящих (план Task 11, Ruling 8/11; аналог _pipeline_loop main.py L79–88): -// каждые 2 с pump'ит ВСЕ тенанты (PipelineWorkerService.PumpOnceAsync под общим PipelinePumpGate) и -// публикует SSE new_card по созданным карточкам — ingest разбирается без ручного tick (приёмка Task 11). -// После StorageTickScheduler: очередь цикла — 2 с, первый проход сразу после старта. -builder.Services.AddHostedService(); - -// Фоновый флашер очереди обучения ML (план Task 16, Ruling 6; аналог _ml_sync_loop main.py): каждые 10 с -// выгружает MlOutbox тенантов в ml-service (TrainBatch, порции по 10, ≤100/цикл; удаление после успеха). -// Регистрируется только в gRPC-режиме (UseLocal=false) — Local-режиму (этапы 2–5) сервис не нужен, очередь -// копится до подключения ml-service (python L56–82); MlGrpcConnection создан в AddDealIntegrations (fail-fast). -if (!mlOptions.UseLocal) -{ - builder.Services.AddHostedService(); -} - -// Фоновый цикл Discovery-воркера (план Task 18, Ruling 10; аналог _discovery_loop main.py): каждые 5 с -// делает ОДИН шаг (поиск/оценка/авто-вступление/done) для каждой running-задачи всех тенантов. Работает -// всегда: в Local-режиме гейт нейтрален (поиск пуст/история недоступна), реальные действия — при -// подключённом telegram-service (UseLocal=false). После Bootstrap: первый проход стартует после провижининга. -builder.Services.AddHostedService(); - -// Сборщик глубин очередей/сессий (этап 12, §10.2): общий источник для метрик и операторского health. -builder.Services.AddSingleton(); - -// Фоновый сборщик gauge-метрик (этап 12, пакет A): каждые 15 с публикует глубины очередей (пайплайн, -// MlOutbox) по всем тенантам и число активных сессий в meter Deal (callback /metrics отдаёт их Prometheus). -// Регистрируется последним из фоновых: после Bootstrap (реестр тенантов провижинен до первого прохода). -builder.Services.AddHostedService(); - -// Фоновый цикл авто-очистки данных (этап 12, пакет B; эталон DealMetricsCollector): раз в сутки удаляет -// записи audit_log старше DataRetention:AuditRetentionDays (дефолт 180 дней), сбрасывает накопительные -// поля лимитов прошедших периодов и убирает завершившиеся окна распределённых счётчиков. Регистрируется -// последним из фоновых: после Bootstrap (реестр тенантов провижинен до первого прохода). -DataRetentionOptions dataRetentionOptions = builder.Configuration - .GetSection(dataRetentionSectionName) - .Get() ?? new DataRetentionOptions(); -builder.Services.AddSingleton(dataRetentionOptions); -builder.Services.AddHostedService(); - -// Кука сессии: имя/срок/Secure из секции "Cookies" (appsettings.json + env Cookies__*). -builder.Services.Configure(builder.Configuration.GetSection(cookiesSectionName)); - -// Кука операторской сессии (Ruling 1 этапа 7): имя deal_operator_session/срок/Secure из секции -// "OperatorCookies" (appsettings.json + env OperatorCookies__*) — отдельная от тенантной deal_session. -builder.Services.Configure(builder.Configuration.GetSection(operatorCookiesSectionName)); - -// Ответы JSON — как в прототипе FastAPI: без \u-экранирования не-ASCII символов. -builder.Services.ConfigureHttpJsonOptions(options => - options.SerializerOptions.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping); - -// Безопасность HTTP (план Task 12, Ruling 10(2)/9): Security:AllowedOrigins — явный allowlist Origin- -// проверки мутаций и CORS (пусто — dev-режим «свой origin», см. AddCors ниже; PROD — домен фронта в -// compose-prod). Инстанс регистрируется в DI: значение читается один раз на старте (политики формируются -// при старте хоста), OriginGuardMiddleware получает его конструктором. -SecurityOptions securityOptions = builder.Configuration - .GetSection(securitySectionName) - .Get() ?? new SecurityOptions(); -builder.Services.AddSingleton(securityOptions); - -// Доверие прокси-заголовкам (план Task 12; замечание ревью T4/T11): ForwardedHeaders — UseForwardedHeaders -// включается env-переопределением (ForwardedHeaders__Enabled=true в PROD за Caddy, compose-prod Task 14); -// dev-дефолт — false (прокси в dev-стеке нет, compose.dev публикует core напрямую). -ForwardedHeadersConfig forwardedHeadersConfig = builder.Configuration - .GetSection(forwardedHeadersSectionName) - .Get() ?? new ForwardedHeadersConfig(); -builder.Services.AddSingleton(forwardedHeadersConfig); - -// CORS (план Task 12, Ruling 10(2)/9): пустой Security:AllowedOrigins — dev-режим «как в прототипе» -// (любой origin/method/header, credentials=true; AllowAnyOrigin + AllowCredentials несовместимы — любой -// origin разрешается предикатом). Непустой список (PROD, Ruling 9) — строгий allowlist + credentials. -// Security-заголовки ответов (nosniff/X-Frame-Options/Referrer-Policy; CSP/HSTS) — на edge (Caddyfile, -// Task 14): наружу статику и /api отдаёт Caddy, core отвечает JSON — на core не дублируются -// (пересмотр Ruling 10(3), см. task-12-report). -builder.Services.AddCors(options => - options.AddPolicy(corsPolicyName, cors => - { - cors.AllowAnyHeader() - .AllowAnyMethod() - .AllowCredentials(); - if (securityOptions.AllowedOrigins.Length == 0) - { - cors.SetIsOriginAllowed(_ => true); - } - else - { - cors.WithOrigins(securityOptions.AllowedOrigins); - } - })); - -var app = builder.Build(); - -// Эндпоинт метрик /metrics (HTTP/1.1 на отдельном порту): формат Prometheus (этап 12, пакет A). -DealMetricsHosting.MapDealMetrics(app); - -// Fail-closed для Production (Security review): дефолты кода рассчитаны на dev/тесты (rate limit выключен, -// CORS — «любой origin»). Прод-окружение обязано задать защиту ЯВНО — иначе старт отказывается, а не -// молча работает без лимитов/с открытым CORS. -if (app.Environment.IsProduction()) -{ - if (!rateLimitOptions.Enabled) - { - throw new InvalidOperationException( - "Production требует RateLimit__Enabled=true (анти-брутфорс и лимиты выключены код-дефолтом)."); - } - - if (securityOptions.AllowedOrigins.Length == 0) - { - throw new InvalidOperationException( - "Production требует непустой Security__AllowedOrigins (CORS fail-open при пустом списке)."); - } -} - -// Стартовый лог выбранного режима файлового хранилища (приёмка Task 6: запуск Api — LocalFileStorage -// с путём data/attachments; при сконфигурированном MinIO — MinioFileStorage с endpoint/бакетом). -app.Logger.LogInformation("Файловое хранилище: {FileStorage}", app.Services.GetRequiredService()); - -// Стартовый лог режима ML-интеграции (приёмка Task 16): Local-заглушка либо gRPC-клиент ml-service. -app.Logger.LogInformation( - "ML-интеграция: {Mode} ({Endpoint})", - mlOptions.UseLocal ? "Local-заглушка (MlOutbox накапливается)" : "gRPC-клиент ml-service", - mlOptions.Endpoint); - -// Стартовый лог режима AI-интеграции (приёмка Task 15): локальный разбор ядра либо gRPC-клиент ai-service -// под декоратором бюджетного гейта (Task 9: исчерпано/приостановлено → локальный разбор, приём не блокируется). -app.Logger.LogInformation( - "AI-интеграция: {Mode} ({Endpoint})", - aiOptions.UseLocal ? "Local-адаптеры (разбор ядра/инструменты выключены)" : "gRPC-клиент ai-service", - aiOptions.Endpoint); - -// Стартовый лог режима Telegram-гейта (приёмка Task 14): Local-заглушка либо gRPC-клиент telegram-service. -app.Logger.LogInformation( - "Telegram-гейт: {Mode} ({Endpoint})", - telegramOptions.UseLocal ? "Local-заглушка (idle/не подключён)" : "gRPC-клиент telegram-service", - telegramOptions.Endpoint); - -// Стартовый лог транспорта внутреннего gRPC (приёмка Task 13, Ruling 6): dev — plaintext + service-token, -// PROD (DEAL_MTLS_ENABLED=1) — mTLS. Пути/пароли не логируются (Ruling 13). -app.Logger.LogInformation( - "Транспорт внутреннего gRPC: {Transport}", - mtlsOptions.Enabled ? "mTLS (DEAL_MTLS_ENABLED=1, сертификаты из DEAL_MTLS_*)" : "plaintext + service-token (dev)"); - -if (forwardedHeadersConfig.Enabled) -{ - // Прокси-заголовки (план Task 12; замечание ревью T4/T11): X-Forwarded-For/X-Forwarded-Proto доверяются - // только клиентам из ForwardedHeaders:KnownProxies/KnownNetworks (конфиг; appsettings — loopback для dev). - // Middleware — ПЕРВЫЙ в конвейере: RemoteIpAddress/Scheme читают слои ниже (CORS, Session/OperatorSession — - // audit-IP эндпоинтов, RateLimiter — ключи по IP, LoginAttemptGuard). Без него за Caddy (compose-prod, - // Task 14) RemoteIpAddress всех запросов = IP Caddy, и audit-IP + rate-limit-по-IP схлопываются в один бакет. - app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig)); -} - -// Access-лог HTTP (Ruling 7, Task 14): первый в конвейере (после ForwardedHeaders) — длительность -// и статус всего пути обработки. gRPC-ингресс (Content-Type application/grpc) middleware пропускает — -// его логирует интерцептор RpcCallLoggingInterceptor (см. HttpAccessLogMiddleware). -app.UseMiddleware(); - -app.UseCors(corsPolicyName); -app.UseMiddleware(); -app.UseMiddleware(); -if (rateLimitOptions.Enabled) -{ - // Порядок middleware — Ruling 5: Session → Operator → RateLimiter (политика "api" ключует по - // CurrentUser.TenantId либо IP анонима; сессии уже разрешены). При Enabled=false лимитер не - // регистрируется (dev-прогон не режет curl-приёмки; OriginGuard Task 12 встанет после). - app.UseRateLimiter(); -} - -// Origin-проверка мутаций /api (план Task 12, Ruling 10(2)): порядок Ruling 5 — Session → Operator → -// RateLimiter → OriginGuard (сессии разрешены, 429 важнее 403). Проверяются не-GET/HEAD/OPTIONS запросы -// с заголовком Origin: Origin == «свой» origin (схема + Host; за Caddy — https из X-Forwarded-Proto) -// либо входит в Security:AllowedOrigins; иначе 403 {detail}. Без Origin (curl/сервер-сервер) пропускаются; -// SameSite=Lax куки остаётся первым рубежом CSRF (фиксируется в техдок §10, Task 16). -app.UseMiddleware(); - -app.MapGet("/api/health", () => Results.Ok(new { ok = true, service = "deal" })); -app.MapAuthEndpoints(); -app.MapOperatorAuthEndpoints(); -app.MapOperatorAuditEndpoints(); -// Операторская аналитика (план этапа 10, T3): read-only сводка/расход токенов/лента действий. -app.MapOperatorAnalyticsEndpoints(); -app.MapOperatorInvitesEndpoints(); -app.MapOperatorTenantsEndpoints(); -// Операторские лимиты/health (план Task 10, Ruling 3/11): сводка и смена бюджета по тенанту (GET/PATCH -// .../limit, аудит tenant_limit_changed) + health ядра/БД и сервисов ml/ai/telegram. -app.MapOperatorLimitsEndpoints(); -app.MapOperatorHealthEndpoints(); -// Глобальные настройки оператора (ТЗ §4.1/§8.1): ключи приложения Telegram — чтение (маска) и смена. -app.MapOperatorSettingsEndpoints(); -// Обслуживание (этап 12, пакет C): идемпотентная пакетная миграция схем всех тенантов реестра -// (ограниченный параллелизм + логирование прогресса) — для SaaS с сотнями/тысячами схем. -app.MapOperatorMaintenanceEndpoints(); -app.MapJoinEndpoint(); -app.MapSettingsEndpoints(); -app.MapAiCheckEndpoint(); -app.MapRatesEndpoints(); -app.MapMlEndpoints(); -app.MapFilterTesterEndpoints(); -app.MapContainersEndpoints(); -app.MapCardsEndpoints(); -app.MapCardDetailsEndpoints(); -app.MapStorageEndpoints(); -app.MapEventsEndpoint(); -app.MapAiSuggestEndpoints(); -app.MapPipelineEndpoints(); -// Эндпоинты /api/tg (план Task 14, Ruling 8): реальный контракт вкладки «Каналы» вместо boot-заглушки -// (BootStubEndpoints удалён); qr-image — отдельным файлом. -app.MapTelegramEndpoints(); -app.MapTelegramQrImageEndpoint(); -// Эндпоинты /api/discovery (план Task 19, Ruling 11): задачи поиска/кандидаты/чёрный список/лог/generate-keywords -// (DiscoveryEndpoints, 1:1 api-map §3.8) — модуль Discovery зарегистрирован AddDiscoveryModule выше. -app.MapDiscoveryEndpoints(); -// gRPC-ингресс и его health освобождены от HTTP-политик rate limiter (план Task 11, Ruling 5): лимит -// входящего потока считает IngressRateLimitInterceptor по tenant-id из metadata (иначе общее окно на IP -// telegram-service резало бы весь ингресс раньше интерцептора); health — инфраструктурный liveness. -app.MapGrpcService().DisableRateLimiting(); -app.MapGrpcHealthChecksService().DisableRateLimiting(); - -app.Run(); - -// ── Kestrel: основной HTTP/1.1-эндпоинт из URL-конфигурации (явные Listen заменяют URL-биндинг) ── - -// Биндит основной HTTP-эндпоинт(ы) из ключа "urls" (--urls/ASPNETCORE_URLS/launchSettings): без явных -// адресов используется фолбэк defaultHttpUrl (как дефолт ASP.NET Core). Схема https — сертификат из -// стандартной конфигурации Kestrel (UseHttps без аргументов), как при URL-биндинге. -static void BindMainHttpEndpoints(KestrelServerOptions kestrel, string? urlsConfig) -{ - List addresses = ParseHttpAddresses(urlsConfig); - if (addresses.Count == 0) - { - addresses.Add(new Uri(defaultHttpUrl)); - } - - foreach (Uri address in addresses) - { - // Dev-запуски используют loopback («localhost»/127.0.0.1) — ListenLocalhost; «0.0.0.0»/«*»/«+»/«::» - // (compose/host) — все интерфейсы. Иных хостов в конфигурации нет (URL-хосты не биндятся по имени). - bool isHttps = string.Equals(address.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase); - string host = address.Host.Trim('[', ']'); - if (host is "0.0.0.0" or "*" or "+" or "::") - { - kestrel.Listen(IPAddress.Any, address.Port, listen => - { - if (isHttps) - { - listen.UseHttps(); - } - }); - } - else if (isHttps) - { - kestrel.ListenLocalhost(address.Port, listen => listen.UseHttps()); - } - else - { - kestrel.ListenLocalhost(address.Port); - } - } -} - -// Разбирает адреса конфигурации "urls" (http/https, разделитель «;»); невалидные/чужие схемы пропускаются. -static List ParseHttpAddresses(string? urlsConfig) -{ - var addresses = new List(); - if (string.IsNullOrWhiteSpace(urlsConfig)) - { - return addresses; - } - - foreach (string part in urlsConfig.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) - { - if (Uri.TryCreate(part, UriKind.Absolute, out Uri? uri) - && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) - { - addresses.Add(uri); - } - } - - return addresses; -} - -// Парсит порт из env-строки; пустое/нечисловое значение — null (фолбэк дефолта). -static int? ParsePort(string? rawValue) - => int.TryParse(rawValue, out int parsedPort) ? parsedPort : null; - -// Дефолт-бюджет нового тенанта из env DEAL_DEFAULT_AI_BUDGET (Ruling 3, Task 8): нечисловое/неположительное -// значение (пустая переменная, опечатка) — константа модуля TokenBudgetDefaults.DefaultBudgetTokens. Период -// всегда month — оператор меняет бюджет/период позже через PATCH лимита (Task 10). -long ResolveDefaultAiBudget(IConfiguration configuration) -{ - string? rawValue = configuration[defaultAiBudgetEnvKey]; - return long.TryParse(rawValue, out long parsedBudget) && parsedBudget > 0 - ? parsedBudget - : TokenBudgetDefaults.DefaultBudgetTokens; -} - -public partial class Program -{ - /// - /// Строит опции UseForwardedHeaders из ForwardedHeadersConfig (план Task 12; замечание ревью - /// T4/T11): обрабатываются X-Forwarded-For/X-Forwarded-Proto ровно одного доверенного hop'а. Списки - /// доверия — строго из конфига KnownProxies/KnownNetworks (appsettings-дефолт — loopback); невалидный - /// IP/CIDR — InvalidOperationException (fail-fast: опечатка в настройке доверия не должна молча - /// отключать обработку). Публичный: unit-тесты опций (ForwardedHeadersHttpTests). - /// - /// Секция ForwardedHeaders конфигурации. - /// Опции для app.UseForwardedHeaders. - public static ForwardedHeadersOptions BuildForwardedHeadersOptions(ForwardedHeadersConfig config) - { - ArgumentNullException.ThrowIfNull(config); - var options = new ForwardedHeadersOptions - { - // Адрес клиента и схема (Origin-проверка «своего» origin за Caddy требует https из - // X-Forwarded-Proto). X-Forwarded-Host не нужен: Caddy транслирует Host без перезаписи. - ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, - // Между клиентом и core ровно один прокси (Caddy): читаем ближайший к нам элемент цепочки. - ForwardLimit = 1, - }; - // Доверие — только явный конфиг + loopback-фолбэк ниже: очищаем дефолты конструктора, чтобы - // перечисление в KnownProxies/KnownIPNetworks было единственным источником истины. - options.KnownProxies.Clear(); - options.KnownIPNetworks.Clear(); - foreach (string proxy in config.KnownProxies) - { - if (!IPAddress.TryParse(proxy, out IPAddress? address)) - { - throw new InvalidOperationException( - $"Невалидный IP доверенного прокси в ForwardedHeaders:KnownProxies: \"{proxy}\"."); - } - - options.KnownProxies.Add(address); - } - - foreach (string network in config.KnownNetworks) - { - if (!TryParseCidr(network, out System.Net.IPNetwork parsedNetwork)) - { - throw new InvalidOperationException( - $"Невалидная подсеть доверенного прокси в ForwardedHeaders:KnownNetworks: \"{network}\" " - + "(ожидается CIDR, напр. 172.16.0.0/12)."); - } - - options.KnownIPNetworks.Add(parsedNetwork); - } - - if (options.KnownProxies.Count == 0 && options.KnownIPNetworks.Count == 0) - { - // Пустые списки KnownProxies/KnownIPNetworks у ForwardedHeadersMiddleware означают «доверять - // ЛЮБОМУ клиенту» (спуфинг XFF) — пустоту до middleware не допускаем. Дефолт без явного - // конфига — loopback (dev-прокси на хосте: vite/локальный Caddy, см. appsettings); PROD - // перечисляет Caddy адресами/подсетями в конфиге. - options.KnownProxies.Add(IPAddress.Loopback); - options.KnownProxies.Add(IPAddress.IPv6Loopback); - } - - return options; - } - - // Разбирает CIDR-запись подсети ("127.0.0.0/8", "2001:db8::/32"); без префикса — вся - // подсеть одного адреса (IPv4 — /32, IPv6 — /128). - // value: Строка конфига (ForwardedHeaders:KnownNetworks). - // network: Разобранная подсеть при успехе. - // Возвращает: true — запись валидна. - private static bool TryParseCidr(string value, out System.Net.IPNetwork network) - { - network = default; - int slashIndex = value.IndexOf('/'); - string addressPart = slashIndex >= 0 ? value[..slashIndex] : value; - if (!IPAddress.TryParse(addressPart, out IPAddress? address)) - { - return false; - } - - int maxPrefixLength = address.AddressFamily == AddressFamily.InterNetwork ? 32 : 128; - int prefixLength = maxPrefixLength; - if (slashIndex >= 0) - { - string lengthPart = value[(slashIndex + 1)..]; - if (!int.TryParse(lengthPart, out prefixLength) || prefixLength < 0 || prefixLength > maxPrefixLength) - { - return false; - } - } - - network = new System.Net.IPNetwork(address, prefixLength); - return true; - } -} +using System.Net; +using System.Net.Sockets; +using System.Text.Encodings.Web; +using Deal.Api; +using Deal.Api.Configuration; +using Deal.Api.Endpoints; +using Deal.Api.Events; +using Deal.Api.Hosting; +using Deal.Api.Http; +using Deal.Api.Logging; +using Deal.Api.Middleware; +using Deal.Api.Observability; +using Deal.Api.Telegram; +using Deal.Contracts.Integrations; +using Deal.Infrastructure; +using Deal.Infrastructure.Data; +using Deal.Infrastructure.Integrations; +using Deal.Infrastructure.Integrations.Storage; +using Deal.Infrastructure.Persistence; +using Deal.Infrastructure.Services; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Telegram.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; +using Deal.SharedKernel.Tenants; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.AspNetCore.Server.Kestrel.Core; +using Microsoft.AspNetCore.Server.Kestrel.Https; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Diagnostics.HealthChecks; +// Имя конфигурационного типа совпадает с Microsoft.AspNetCore.Http.CookieOptions — фиксируем алиасом. +using CookieOptions = Deal.Api.Configuration.CookieOptions; + +const string cookiesSectionName = "Cookies"; +// Секция настроек куки оператора (Ruling 1): имя deal_operator_session, срок 12 ч, Secure из конфига. +const string operatorCookiesSectionName = "OperatorCookies"; +// Имя CORS-политики (план Task 12, Ruling 10): одна политика, режим зависит от Security:AllowedOrigins. +const string corsPolicyName = "cors"; +// Секции конфигурации интеграций (Ruling 6): Services:Ml / Services:Ai / Services:Telegram → {UseLocal, Endpoint}. +const string servicesSectionName = "Services:Ml"; +const string aiServicesSectionName = "Services:Ai"; +const string telegramServicesSectionName = "Services:Telegram"; +// Имя истории tenant-миграций без схемы (схема — через search_path; миграции применяет +// TenantProvisioningService на старте, runtime-контекст их не выполняет). +const string tenantMigrationsHistoryTable = "__TenantMigrationsHistory"; +// Порт gRPC-ингресса по умолчанию (Ruling 7): :5082; переопределяется env GRPC_INGRESS_PORT. +const int defaultIngressPort = 5082; +const string ingressPortEnvKey = "GRPC_INGRESS_PORT"; +// Ключ конфигурации адресов основного HTTP-эндпоинта (--urls/ASPNETCORE_URLS/launchSettings). +const string serverUrlsKey = "urls"; +// Фолбэк основного HTTP-адреса при отсутствии явных URL (дефолт ASP.NET Core http://localhost:5000). +const string defaultHttpUrl = "http://localhost:5000"; +// Ключ env-переопределения дефолт-бюджета нового тенанта (Ruling 3; токенов в месяц, период — всегда month). +const string defaultAiBudgetEnvKey = "DEAL_DEFAULT_AI_BUDGET"; +// Секция настроек rate limiting (план Task 11, Ruling 5): RateLimit:Enabled=false в dev/тестах по +// умолчанию (curl-приёмки не режутся); PROD включает env-переопределением RateLimit__Enabled=true +// (compose-prod, Task 14). +const string rateLimitSectionName = "RateLimit"; +// Секция авто-очистки данных (этап 12, пакет B): DataRetention:AuditRetentionDays (дефолт 180) + Enabled — +// фоновый цикл DataRetentionScheduler чистит audit_log по retention, лимиты/счётчики прошедших окон. +const string dataRetentionSectionName = "DataRetention"; +// Секция настроек безопасности HTTP (план Task 12, Ruling 10): Security:AllowedOrigins — явный allowlist +// Origin-проверки/CORS (пусто — dev-режим «свой origin» + CORS-любой; PROD — домены фронта, Ruling 9). +const string securitySectionName = "Security"; +// Секция доверия прокси-заголовкам (план Task 12; замечание ревью T4/T11): ForwardedHeaders:KnownProxies/ +// KnownNetworks — доверенные прокси (Caddy в PROD); dev-дефолт в appsettings — loopback. +const string forwardedHeadersSectionName = "ForwardedHeaders"; + +// Имя процесса для rolling-файла логов (Ruling 7, Task 14): data/logs/deal-core-<дата>.json. +const string coreProcessName = "core"; + +var builder = WebApplication.CreateBuilder(args); + +// Структурированные логи Serilog (Ruling 7, план Task 14): консоль JSON в prod / текст в dev + +// rolling-файл data/logs/deal-core-*.json (data — volume контейнера). Уровень/каталог — env +// DEAL_LOG_LEVEL/DEAL_LOGS_DIR (см. DealLogging). Регистрируется до остальных сервисов: логирование +// заменяет провайдеры Microsoft при builder.Build(). +DealLogging.Configure(builder, coreProcessName); + +// Метрики OpenTelemetry → Prometheus (этап 12, пакет A): эндпоинт /metrics в формате Prometheus на +// отдельном HTTP/1.1 Kestrel-эндпоинте (порт 9464/env METRICS_PORT) + инструментация входящих HTTP- +// запросов и исходящих HTTP-клиентов; прикладные метрики — SharedKernel/Observability/DealMetrics. +int metricsPort = DealMetricsHosting.ResolveMetricsPort(DealMetricsHosting.DefaultMetricsPort); +DealMetricsHosting.AddDealMetrics(builder, metricsPort); + +// Строка подключения Postgres — ТОЛЬКО из конфигурации (env/appsettings): dev-пароль в коде отсутствует +// (Security review). Отсутствие строки = fail-fast на старте, а не тихий уход на несуществующую dev-БД. +var connectionString = builder.Configuration.GetConnectionString("DealPostgres") + ?? throw new InvalidOperationException("ConnectionStrings:DealPostgres не задан"); +builder.Services.AddDbContext(options => options.UseNpgsql(connectionString)); +builder.Services.AddSingleton(); +builder.Services.AddSingleton(); + +// mTLS внутреннего gRPC-транспорта (Ruling 6, план Task 13): env DEAL_MTLS_* — флаг и пути/пароли +// сертификатов (только env, Ruling 13). Dev-дефолт — выключено: сервисы и ингресс остаются на plaintext + +// service-token (Ruling 2 этапа 6); compose-prod (Task 14) передаёт env и монтирует deploy/certs +// (генерация — scripts/mtls-certs.sh). При DEAL_MTLS_ENABLED=1 сертификаты грузятся сразу (fail-fast на +// битые пути/пароли) — один экземпляр используют и Kestrel-ингресс ниже, и gRPC-клиенты +// (Ml/Ai/Telegram-каналы + ServiceHealthProbe). +MtlsOptions mtlsOptions = MtlsOptions.FromConfiguration(builder.Configuration); +MtlsCertificates? mtlsCertificates = MtlsCertificates.Load(mtlsOptions); +if (mtlsCertificates is not null) +{ + builder.Services.AddSingleton(mtlsCertificates); +} + +// Kestrel: основной HTTP/1.1-эндпоинт из URL-конфигурации (как раньше — --urls/ASPNETCORE_URLS/ +// launchSettings) + второй endpoint gRPC-ингресса telegram-service (:5082, HTTP/2, env GRPC_INGRESS_PORT; +// план Task 12, Ruling 7). Явные Listen заменяют URL-биндинг Kestrel, поэтому основной эндпоинт +// пере-биндим адресами конфигурации "urls" явно (см. BindMainHttpEndpoints ниже). Ingress слушает все +// интерфейсы (AnyIP): в dev к нему ходит telegram-service из compose-сети через host.docker.internal (Ruling 12). +builder.WebHost.ConfigureKestrel(kestrel => +{ + BindMainHttpEndpoints(kestrel, builder.Configuration[serverUrlsKey]); + int ingressPort = ParsePort(builder.Configuration[ingressPortEnvKey]) ?? defaultIngressPort; + kestrel.ListenAnyIP(ingressPort, listen => + { + listen.Protocols = HttpProtocols.Http2; + if (mtlsCertificates is not null) + { + // mTLS-ингресс (Ruling 6, Task 13): HTTPS с серверным сертификатом core + обязательный клиентский + // сертификат (цепочка до CA из DEAL_MTLS_CA_PEM). Основной HTTP :5080 остаётся http — TLS наружу + // терминирует Caddy (Ruling 9, compose-prod Task 14). + listen.UseHttps(https => + { + https.ServerCertificate = mtlsCertificates.ServerCertificate; + https.ClientCertificateMode = ClientCertificateMode.RequireCertificate; + https.ClientCertificateValidation = mtlsCertificates.ValidateClientCertificate; + }); + } + }); +}); + +// TenantDbContext — scoped-контекст бессхемной модели тенанта (таблица settings и др.): строка +// подключения на каждый scope строится по текущему ITenantContext (заполняет SessionMiddleware) +// с Search Path на схему тенанта (ConnectionStringProvider.ForTenant). Опции живут в scope запроса +// (optionsLifetime: Scoped) — иначе опции с первым тенантом закешировались бы в singleton. +// Безопасность: вне tenant-запроса (нет сессии) контекст не имеет смысла — ошибка конфигурации. +builder.Services.AddDbContext( + (serviceProvider, options) => + { + var tenantContext = serviceProvider.GetRequiredService(); + if (!tenantContext.HasTenant) + { + throw new InvalidOperationException( + "TenantDbContext запрошен вне tenant-запроса: на запрос не разрешена сессия " + + "(ITenantContext.HasTenant == false)."); + } + + var connectionStringProvider = serviceProvider.GetRequiredService(); + options.UseNpgsql( + connectionStringProvider.ForTenant(tenantContext.TenantId), + npgsql => npgsql.MigrationsHistoryTable(tenantMigrationsHistoryTable)); + }, + contextLifetime: ServiceLifetime.Scoped, + optionsLifetime: ServiceLifetime.Scoped); + +// Модуль Tenants и его EF-адаптеры («port & adapter», Ruling 1). +builder.Services.AddTenantsModule(); + +// Лимиты ИИ-бюджета (Ruling 3, Task 8): дефолт-бюджет лениво создаваемой строки public.tenant_limits — +// env DEAL_DEFAULT_AI_BUDGET (токенов в месяц) с фолбэком на константу модуля TokenBudgetDefaults (10 000 000); +// период нового тенанта — month (константа). Значение читается один раз на старте и передаётся адаптеру +// TenantLimitStore (GetOrCreateAsync при первом чтении/списании, задачи 7/10 list-путь тоже закрыт). +TokenLimitDefaults tenantLimitDefaults = new( + ResolveDefaultAiBudget(builder.Configuration), TokenBudgetDefaults.DefaultPeriod); +builder.Services.AddDealPersistence(tenantLimitDefaults); + +// Шифрование секретов (Ruling 2): ISecretCipher — AES-256-GCM; ключ из DEAL_ENCRYPTION_KEY +// либо файла data/encryption.key под ContentRoot (dev). Ключ разрешается на старте — +// невалидный env-ключ останавливает запуск. +builder.Services.AddDealSecurity(builder.Environment.ContentRootPath); + +// Внешние интеграции (Tasks 9/16/15, Rulings 4/5/6/9): IMlClient — детерминированная заглушка LocalMlClient +// (Services:Ml:UseLocal=true, default; обучение — этап 3) либо gRPC-клиент GrpcMlClient (UseLocal=false, +// ml-service :5103). Local-адаптеры читают KV-настройки тенанта через ISettingsStore — scoped (вне +// tenant-запроса не разрешимы). IColumnSuggester — LocalColumnSuggester (эвристика, Self-Review L525–527). +MlServiceOptions mlOptions = builder.Configuration.GetSection(servicesSectionName).Get() ?? new MlServiceOptions(); +builder.Services.AddSingleton(mlOptions); +// AI-интеграция (план Task 15, Ruling 6/9): IAiClassifier/IAiTools — Local-адаптеры (Services:Ai:UseLocal=true, +// default; локальный разбор ядра / инструменты не поддерживаются) либо декораторы бюджетного гейта поверх +// gRPC-клиентов ai-service (UseLocal=false, ai-service :5102; AddDealIntegrations регистрирует GrpcAiClassifier/ +// GrpcAiTools + транспорт AiGrpcConnection — fail-fast, как MlGrpcConnection — и оборачивает их в +// BudgetedAiClassifier/BudgetedAiTools, Ruling 3/Task 9). +AiServiceOptions aiOptions = builder.Configuration.GetSection(aiServicesSectionName).Get() ?? new AiServiceOptions(); +builder.Services.AddSingleton(aiOptions); +// Telegram-гейт (план Task 14, Ruling 6/7): LocalTelegramGateway (Services:Telegram:UseLocal=true, default — +// нейтральный no-op/idle) либо gRPC-клиент GrpcTelegramClient (UseLocal=false, telegram-service :5101). +TelegramServiceOptions telegramOptions = builder.Configuration.GetSection(telegramServicesSectionName).Get() ?? new TelegramServiceOptions(); +builder.Services.AddSingleton(telegramOptions); +builder.Services.AddDealIntegrations(mlOptions, aiOptions, telegramOptions, mtlsCertificates); + +// Health-проба автономных сервисов для операторского health (план Task 10, Ruling 3/6/9): grpc.health.v1 +// к Services:*:Endpoint с дедлайном 3 с (ServiceHealthProbe). Stateless, singleton — пробы строят +// короткоживущие каналы на каждый вызов (mTLS-каналы — при включённом флаге, Task 13/Ruling 6). +builder.Services.AddSingleton(new ServiceHealthProbe(mtlsCertificates)); + +// Файловое хранилище вложений проектных карточек (Ruling 4, Task 6): LocalFileStorage (data/attachments +// под ContentRoot) — dev/curl/unit по умолчанию; MinioFileStorage регистрируется, только когда сконфигурирован +// MinIO (секция Storage:Minio либо env-алиасы DEAL_MINIO_*; compose-сервис deal-minio, порты 9000/9001). +// Singleton: хранилище не привязано к схеме тенанта (объекты — в едином бакете/каталоге, мульти-аренда +// объектного хранилища — этап 7 SaaS). Режим логируется на старте (см. ниже) — приёмка Task 6. +builder.Services.AddDealFileStorage(builder.Configuration, builder.Environment.ContentRootPath); + +// Модуль Settings (сервис настроек тенанта); адаптеры ISettingsStore/ISecretCipher уже +// зарегистрированы AddDealPersistence/AddDealSecurity выше (см. Task 4). +builder.Services.AddSettingsModule(); + +// Модуль Kanban — единый домен карточки (Ruling 12): регистратор сервисов карточек/контейнеров/тиков; +// порт-адаптер ICardStore → KanbanStore уже зарегистрирован AddDealPersistence. +builder.Services.AddKanbanModule(); + +// Модуль Pipeline (Ruling 10, Task 9): приём/обработка/воркер и ядра разбора этапа 4. Порт-адаптер +// IPipelineStore → PipelineStore и внешние порты (IMlClient/IAiClassifier) уже зарегистрированы +// AddDealPersistence/AddDealIntegrations выше; сервисы модуля вызывают из эндпоинтов /api/pipeline/* +// и gRPC-ингресса telegram-service, pump — admin/tick (Task 10) и фоновый цикл (Task 11). +builder.Services.AddPipelineModule(); + +// Модуль Telegram (план Task 13, Ruling 7): сервис каталога диалогов (DialogsService) — владелец таблиц +// Dialogs/TgMessages схемы тенанта (миграция TenantTelegram). Порт-адаптеры ITelegramStore → TelegramStore и +// ITelegramGateway → LocalTelegramGateway/GrpcTelegramClient зарегистрированы AddDealPersistence/AddDealIntegrations +// выше; сервис зовут gRPC-ингресс (SyncDialogs/PushMessage) и эндпоинты /api/tg (Task 14). +builder.Services.AddTelegramModule(); + +// Модуль Discovery (план Task 17/18, Ruling 9/10): сервисы задач/кандидатов/чёрного списка/лога, план-бюджет +// и воркер (оценка/бан-гард/паузы). Порт-адаптер IDiscoveryStore → DiscoveryStore зарегистрирован +// AddDealPersistence; внешние порты (ITelegramGateway/IAiTools/IMlClient) — AddDealIntegrations выше. Эндпоинты +// /api/discovery добавляет Task 19; фоновый цикл воркера — DiscoveryWorkerScheduler ниже. +builder.Services.AddDiscoveryModule(); + +// Статус/ключи вкладки Telegram (план Task 14, Ruling 8): сборка GET /api/tg/status (гейт + KV tgAccount + +// счётчик мониторящихся + keysSet) и чтение глобальных ключей приложения (telegramKeys в public.global_settings, +// расшифровка apiHash — задаёт оператор, ТЗ §4.1/§8.1). Scoped: зависимости — ISettingsStore/ITelegramStore +// на TenantDbContext схемы тенанта запроса, IGlobalSettingsStore — на системном DealDbContext. +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +// Фоновые спуски перечитывания каналов (план Task 14, Ruling 8; аналог python-_spawn роутеров): эндпоинты +// мониторинга/«Перечитать» отвечают сразу, тяжёлый разбор идёт в отдельном scope с захваченным tenant-контекстом. +builder.Services.AddSingleton(); + +// Оркестратор ручного тика (план Task 10, Rulings 8/9): тик правил хранения (Kanban) + очистка отсева +// Pipeline + один проход pump + SSE-публикации (тосты/new_card) для POST /api/admin/tick (StorageEndpoints). +// Scoped: зависимости живут в рамках tenant-запроса (scoped-сервисы модулей на TenantDbContext схемы). +builder.Services.AddScoped(); + +// Обслуживание FTS-индексов схемы тенанта (Ruling 6, план Task 10): POST /api/admin/fts/rebuild — +// CREATE INDEX IF NOT EXISTS + ANALYZE (FtsMaintenance) на TenantDbContext запроса (scoped, как адаптеры). +builder.Services.AddScoped(); + +// SSE-брокер этапа (Ruling 5): singleton per-tenant каналов событий; подписка — GET /api/events, +// публикации — из эндпоинтов Api после вызова сервисов (Tasks 10/13/14). +builder.Services.AddSingleton(); + +// SSE-тосты статистики тика правил хранения (Ruling 8): единый хелпер для POST /api/admin/tick +// (StorageEndpoints) и фонового StorageTickScheduler (Task 11) — без дублирования текстов/иконок. +builder.Services.AddSingleton(); + +// Общий воркер-гейт pump тенанта (план Task 11, Ruling 8; аналог asyncio.Lock pipeline.py L38–42): +// POST /api/admin/tick (AdminTickOrchestrator) и фоновый цикл разбора очереди (PipelineWorkerScheduler) +// не разбирают очередь одного тенанта одновременно (singleton per-tenant флагов, Interlocked). +builder.Services.AddSingleton(); + +// Rate limiting и защита входа (план Task 11, Ruling 5; этап 12, пакет B — хранилище на Postgres): секция +// "RateLimit" (appsettings.json + env RateLimit__*). Enabled=false в dev/тестах — политики/middleware/ +// интерцептор не регистрируются вовсе (Ruling 5: «в dev выключено — curl-приёмки не режутся»); +// LoginAttemptGuard (окно ip|login 5 неудач/15 мин в public.rate_limit_counters) регистрируется всегда, +// но активен только при Enabled. +RateLimitOptions rateLimitOptions = builder.Configuration + .GetSection(rateLimitSectionName) + .Get() ?? new RateLimitOptions(); +builder.Services.AddSingleton(rateLimitOptions); +// Гвард попыток входа — scoped: его хранилище счётчиков (IRateLimitCounterStore) — scoped EF-адаптер +// (public.rate_limit_counters). Активен только при Enabled (no-op иначе). +builder.Services.AddScoped(); +if (rateLimitOptions.Enabled) +{ + builder.Services.AddDealRateLimiter(rateLimitOptions); +} + +// gRPC-ингресс telegram-service (план Task 12, Ruling 1/7): сервер Deal.Grpc.Telegram.IngressService +// на отдельном Kestrel-endpoint (:5082, HTTP/2, см. ConfigureKestrel выше) в том же процессе. Token +// из metadata «service-token» проверяет интерцептор (fail-closed, DEAL_SERVICE_TOKEN); AddAuthentication +// не нужен — пользовательская сессия HTTP ингрессом не используется (tenant-id из metadata → SetTenant). +builder.Services.AddGrpc(grpc => +{ + // Access-лог RPC ингресса (Ruling 7, Task 14): ПЕРВЫМ в цепочке — логируются и отклонённые + // вызовы (401/429); gRPC-health не логируется (см. RpcCallLoggingInterceptor). + grpc.Interceptors.Add(); + grpc.Interceptors.Add(); + if (rateLimitOptions.Enabled) + { + // Лимит входящего потока по tenant-id (план Task 11, Ruling 5): окно считает общий + // singleton-лимитер (CreateLimiter) — экземпляры интерцептора общий PartitionedRateLimiter + // разделяют; health-методы освобождены (см. IngressRateLimitInterceptor). + grpc.Interceptors.Add(); + } +}); +if (rateLimitOptions.Enabled) +{ + builder.Services.AddSingleton(provider => + IngressRateLimitInterceptor.CreateLimiter( + provider.GetRequiredService(), + rateLimitOptions.GrpcIngressPerMinute)); +} + +builder.Services.AddScoped(); + +// gRPC-health ингресса (план Task 20, Ruling 12): healthcheck контейнера core в docker compose. +// grpc.health.v1.Health интерцептор токеном не проверяет (инфраструктурный liveness, как в сервисах +// этапа T2–T4); регистрируется явная проверка "ready" — без неё health-сервис отвечает UNKNOWN. +// Живучесть интеграций (ml/ai/telegram) health не проверяет — недоступность сервиса это UNAVAILABLE +// на RPC и фолбэк Local-адаптеров, а не падение хоста (Ruling 6). +builder.Services + .AddGrpcHealthChecks() + .AddCheck("ready", () => HealthCheckResult.Healthy("хост Deal.Api готов")); + +// Проверка подключения AI-провайдера (Task 6, Ruling 7): порт модуля IAiConnectionChecker → +// HTTP-адаптер Infrastructure с собственным HttpClient (фабрика AddHttpClient, таймаут 12 с). +// HTTP наружу ходит только по действию Settings-экрана (POST /api/ai/check) — GET {base}/models. +builder.Services.AddHttpClient( + client => client.Timeout = TimeSpan.FromSeconds(AiConnectionChecker.RequestTimeoutSeconds)); + +// Курсы валют (Task 8, Ruling 6): порт модуля IRatesSource → HTTP-адаптер ЦБ с собственным +// HttpClient (таймаут 15 с, как httpx timeout=15 в rates.py). URL — фиксированная константа +// адаптера (SSRF-allowlist), источник тенантом не настраивается. Типизированный клиент +// регистрируется transient и живёт в рамках scope запроса (как IAiConnectionChecker, Task 6). +builder.Services.AddHttpClient( + client => client.Timeout = TimeSpan.FromSeconds(CbrRateSource.RequestTimeoutSeconds)); + +// Фоновое обновление кэша курсов вне запроса (Ruling 6): PATCH rateSource и лениво на GET — +// собственный scope + in-flight guard (см. RatesRefreshScheduler). +builder.Services.AddSingleton(); + +// Bootstrap при старте (Ruling 8): дефолтный тенант + admin, провижининг схем всех тенантов. +builder.Services.AddHostedService(); + +// Bootstrap оператора при старте (Ruling 1 этапа 7): env DEAL_OPERATOR_LOGIN/DEAL_OPERATOR_PASSWORD, +// dev-дефолт operator/operator в Development; в Production без env — warning и пропуск. Идёт после +// TenantBootstrapService: операторские public-таблицы не зависят от провижининга схем тенантов. +builder.Services.AddHostedService(); + +// Фоновый цикл правил хранения (план Task 11, Ruling 8; аналог _storage_loop main.py L43–53): каждые +// 30 с тикает ВСЕ тенанты (StorageTickService + автоочистка отсева пайплайна 3 суток) и публикует +// SSE-тосты. Регистрируется после Bootstrap — первый проход стартует уже после провижининга схем. +builder.Services.AddHostedService(); + +// Фоновый цикл SSE-алертов ИИ-бюджета (план Task 9, Ruling 3; эталон StorageTickScheduler): каждые 60 с +// проверяет ВСЕ тенанты и публикует в канал тенанта тост при пересечении порогов 80/100% (TryMark*-CAS — +// один тост на порог за период). Идёт после Bootstrap: реестр тенантов провижинен до первого прохода. +builder.Services.AddHostedService(); + +// Фоновый цикл разбора очереди входящих (план Task 11, Ruling 8/11; аналог _pipeline_loop main.py L79–88): +// каждые 2 с pump'ит ВСЕ тенанты (PipelineWorkerService.PumpOnceAsync под общим PipelinePumpGate) и +// публикует SSE new_card по созданным карточкам — ingest разбирается без ручного tick (приёмка Task 11). +// После StorageTickScheduler: очередь цикла — 2 с, первый проход сразу после старта. +builder.Services.AddHostedService(); + +// Фоновый флашер очереди обучения ML (план Task 16, Ruling 6; аналог _ml_sync_loop main.py): каждые 10 с +// выгружает MlOutbox тенантов в ml-service (TrainBatch, порции по 10, ≤100/цикл; удаление после успеха). +// Регистрируется только в gRPC-режиме (UseLocal=false) — Local-режиму (этапы 2–5) сервис не нужен, очередь +// копится до подключения ml-service (python L56–82); MlGrpcConnection создан в AddDealIntegrations (fail-fast). +if (!mlOptions.UseLocal) +{ + builder.Services.AddHostedService(); +} + +// Фоновый цикл Discovery-воркера (план Task 18, Ruling 10; аналог _discovery_loop main.py): каждые 5 с +// делает ОДИН шаг (поиск/оценка/авто-вступление/done) для каждой running-задачи всех тенантов. Работает +// всегда: в Local-режиме гейт нейтрален (поиск пуст/история недоступна), реальные действия — при +// подключённом telegram-service (UseLocal=false). После Bootstrap: первый проход стартует после провижининга. +builder.Services.AddHostedService(); + +// Сборщик глубин очередей/сессий (этап 12, §10.2): общий источник для метрик и операторского health. +builder.Services.AddSingleton(); + +// Фоновый сборщик gauge-метрик (этап 12, пакет A): каждые 15 с публикует глубины очередей (пайплайн, +// MlOutbox) по всем тенантам и число активных сессий в meter Deal (callback /metrics отдаёт их Prometheus). +// Регистрируется последним из фоновых: после Bootstrap (реестр тенантов провижинен до первого прохода). +builder.Services.AddHostedService(); + +// Фоновый цикл авто-очистки данных (этап 12, пакет B; эталон DealMetricsCollector): раз в сутки удаляет +// записи audit_log старше DataRetention:AuditRetentionDays (дефолт 180 дней), сбрасывает накопительные +// поля лимитов прошедших периодов и убирает завершившиеся окна распределённых счётчиков. Регистрируется +// последним из фоновых: после Bootstrap (реестр тенантов провижинен до первого прохода). +DataRetentionOptions dataRetentionOptions = builder.Configuration + .GetSection(dataRetentionSectionName) + .Get() ?? new DataRetentionOptions(); +builder.Services.AddSingleton(dataRetentionOptions); +builder.Services.AddHostedService(); + +// Кука сессии: имя/срок/Secure из секции "Cookies" (appsettings.json + env Cookies__*). +builder.Services.Configure(builder.Configuration.GetSection(cookiesSectionName)); + +// Кука операторской сессии (Ruling 1 этапа 7): имя deal_operator_session/срок/Secure из секции +// "OperatorCookies" (appsettings.json + env OperatorCookies__*) — отдельная от тенантной deal_session. +builder.Services.Configure(builder.Configuration.GetSection(operatorCookiesSectionName)); + +// Ответы JSON — как в прототипе FastAPI: без \u-экранирования не-ASCII символов. +builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping); + +// Безопасность HTTP (план Task 12, Ruling 10(2)/9): Security:AllowedOrigins — явный allowlist Origin- +// проверки мутаций и CORS (пусто — dev-режим «свой origin», см. AddCors ниже; PROD — домен фронта в +// compose-prod). Инстанс регистрируется в DI: значение читается один раз на старте (политики формируются +// при старте хоста), OriginGuardMiddleware получает его конструктором. +SecurityOptions securityOptions = builder.Configuration + .GetSection(securitySectionName) + .Get() ?? new SecurityOptions(); +builder.Services.AddSingleton(securityOptions); + +// Доверие прокси-заголовкам (план Task 12; замечание ревью T4/T11): ForwardedHeaders — UseForwardedHeaders +// включается env-переопределением (ForwardedHeaders__Enabled=true в PROD за Caddy, compose-prod Task 14); +// dev-дефолт — false (прокси в dev-стеке нет, compose.dev публикует core напрямую). +ForwardedHeadersConfig forwardedHeadersConfig = builder.Configuration + .GetSection(forwardedHeadersSectionName) + .Get() ?? new ForwardedHeadersConfig(); +builder.Services.AddSingleton(forwardedHeadersConfig); + +// CORS (план Task 12, Ruling 10(2)/9): пустой Security:AllowedOrigins — dev-режим «как в прототипе» +// (любой origin/method/header, credentials=true; AllowAnyOrigin + AllowCredentials несовместимы — любой +// origin разрешается предикатом). Непустой список (PROD, Ruling 9) — строгий allowlist + credentials. +// Security-заголовки ответов (nosniff/X-Frame-Options/Referrer-Policy; CSP/HSTS) — на edge (Caddyfile, +// Task 14): наружу статику и /api отдаёт Caddy, core отвечает JSON — на core не дублируются +// (пересмотр Ruling 10(3), см. task-12-report). +builder.Services.AddCors(options => + options.AddPolicy(corsPolicyName, cors => + { + cors.AllowAnyHeader() + .AllowAnyMethod() + .AllowCredentials(); + if (securityOptions.AllowedOrigins.Length == 0) + { + cors.SetIsOriginAllowed(_ => true); + } + else + { + cors.WithOrigins(securityOptions.AllowedOrigins); + } + })); + +var app = builder.Build(); + +// Эндпоинт метрик /metrics (HTTP/1.1 на отдельном порту): формат Prometheus (этап 12, пакет A). +DealMetricsHosting.MapDealMetrics(app); + +// Fail-closed для Production (Security review): дефолты кода рассчитаны на dev/тесты (rate limit выключен, +// CORS — «любой origin»). Прод-окружение обязано задать защиту ЯВНО — иначе старт отказывается, а не +// молча работает без лимитов/с открытым CORS. +if (app.Environment.IsProduction()) +{ + if (!rateLimitOptions.Enabled) + { + throw new InvalidOperationException( + "Production требует RateLimit__Enabled=true (анти-брутфорс и лимиты выключены код-дефолтом)."); + } + + if (securityOptions.AllowedOrigins.Length == 0) + { + throw new InvalidOperationException( + "Production требует непустой Security__AllowedOrigins (CORS fail-open при пустом списке)."); + } +} + +// Стартовый лог выбранного режима файлового хранилища (приёмка Task 6: запуск Api — LocalFileStorage +// с путём data/attachments; при сконфигурированном MinIO — MinioFileStorage с endpoint/бакетом). +app.Logger.LogInformation("Файловое хранилище: {FileStorage}", app.Services.GetRequiredService()); + +// Стартовый лог режима ML-интеграции (приёмка Task 16): Local-заглушка либо gRPC-клиент ml-service. +app.Logger.LogInformation( + "ML-интеграция: {Mode} ({Endpoint})", + mlOptions.UseLocal ? "Local-заглушка (MlOutbox накапливается)" : "gRPC-клиент ml-service", + mlOptions.Endpoint); + +// Стартовый лог режима AI-интеграции (приёмка Task 15): локальный разбор ядра либо gRPC-клиент ai-service +// под декоратором бюджетного гейта (Task 9: исчерпано/приостановлено → локальный разбор, приём не блокируется). +app.Logger.LogInformation( + "AI-интеграция: {Mode} ({Endpoint})", + aiOptions.UseLocal ? "Local-адаптеры (разбор ядра/инструменты выключены)" : "gRPC-клиент ai-service", + aiOptions.Endpoint); + +// Стартовый лог режима Telegram-гейта (приёмка Task 14): Local-заглушка либо gRPC-клиент telegram-service. +app.Logger.LogInformation( + "Telegram-гейт: {Mode} ({Endpoint})", + telegramOptions.UseLocal ? "Local-заглушка (idle/не подключён)" : "gRPC-клиент telegram-service", + telegramOptions.Endpoint); + +// Стартовый лог транспорта внутреннего gRPC (приёмка Task 13, Ruling 6): dev — plaintext + service-token, +// PROD (DEAL_MTLS_ENABLED=1) — mTLS. Пути/пароли не логируются (Ruling 13). +app.Logger.LogInformation( + "Транспорт внутреннего gRPC: {Transport}", + mtlsOptions.Enabled ? "mTLS (DEAL_MTLS_ENABLED=1, сертификаты из DEAL_MTLS_*)" : "plaintext + service-token (dev)"); + +if (forwardedHeadersConfig.Enabled) +{ + // Прокси-заголовки (план Task 12; замечание ревью T4/T11): X-Forwarded-For/X-Forwarded-Proto доверяются + // только клиентам из ForwardedHeaders:KnownProxies/KnownNetworks (конфиг; appsettings — loopback для dev). + // Middleware — ПЕРВЫЙ в конвейере: RemoteIpAddress/Scheme читают слои ниже (CORS, Session/OperatorSession — + // audit-IP эндпоинтов, RateLimiter — ключи по IP, LoginAttemptGuard). Без него за Caddy (compose-prod, + // Task 14) RemoteIpAddress всех запросов = IP Caddy, и audit-IP + rate-limit-по-IP схлопываются в один бакет. + app.UseForwardedHeaders(BuildForwardedHeadersOptions(forwardedHeadersConfig)); +} + +// Access-лог HTTP (Ruling 7, Task 14): первый в конвейере (после ForwardedHeaders) — длительность +// и статус всего пути обработки. gRPC-ингресс (Content-Type application/grpc) middleware пропускает — +// его логирует интерцептор RpcCallLoggingInterceptor (см. HttpAccessLogMiddleware). +app.UseMiddleware(); + +app.UseCors(corsPolicyName); +app.UseMiddleware(); +app.UseMiddleware(); +if (rateLimitOptions.Enabled) +{ + // Порядок middleware — Ruling 5: Session → Operator → RateLimiter (политика "api" ключует по + // CurrentUser.TenantId либо IP анонима; сессии уже разрешены). При Enabled=false лимитер не + // регистрируется (dev-прогон не режет curl-приёмки; OriginGuard Task 12 встанет после). + app.UseRateLimiter(); +} + +// Origin-проверка мутаций /api (план Task 12, Ruling 10(2)): порядок Ruling 5 — Session → Operator → +// RateLimiter → OriginGuard (сессии разрешены, 429 важнее 403). Проверяются не-GET/HEAD/OPTIONS запросы +// с заголовком Origin: Origin == «свой» origin (схема + Host; за Caddy — https из X-Forwarded-Proto) +// либо входит в Security:AllowedOrigins; иначе 403 {detail}. Без Origin (curl/сервер-сервер) пропускаются; +// SameSite=Lax куки остаётся первым рубежом CSRF (фиксируется в техдок §10, Task 16). +app.UseMiddleware(); + +app.MapGet("/api/health", () => Results.Ok(new { ok = true, service = "deal" })); +app.MapAuthEndpoints(); +app.MapOperatorAuthEndpoints(); +app.MapOperatorAuditEndpoints(); +// Операторская аналитика (план этапа 10, T3): read-only сводка/расход токенов/лента действий. +app.MapOperatorAnalyticsEndpoints(); +app.MapOperatorInvitesEndpoints(); +app.MapOperatorTenantsEndpoints(); +// Операторские лимиты/health (план Task 10, Ruling 3/11): сводка и смена бюджета по тенанту (GET/PATCH +// .../limit, аудит tenant_limit_changed) + health ядра/БД и сервисов ml/ai/telegram. +app.MapOperatorLimitsEndpoints(); +app.MapOperatorHealthEndpoints(); +// Глобальные настройки оператора (ТЗ §4.1/§8.1): ключи приложения Telegram — чтение (маска) и смена. +app.MapOperatorSettingsEndpoints(); +// Обслуживание (этап 12, пакет C): идемпотентная пакетная миграция схем всех тенантов реестра +// (ограниченный параллелизм + логирование прогресса) — для SaaS с сотнями/тысячами схем. +app.MapOperatorMaintenanceEndpoints(); +app.MapJoinEndpoint(); +app.MapSettingsEndpoints(); +app.MapAiCheckEndpoint(); +app.MapRatesEndpoints(); +app.MapMlEndpoints(); +app.MapFilterTesterEndpoints(); +app.MapContainersEndpoints(); +app.MapCardsEndpoints(); +app.MapCardDetailsEndpoints(); +app.MapStorageEndpoints(); +app.MapEventsEndpoint(); +app.MapAiSuggestEndpoints(); +app.MapPipelineEndpoints(); +// Эндпоинты /api/tg (план Task 14, Ruling 8): реальный контракт вкладки «Каналы» вместо boot-заглушки +// (BootStubEndpoints удалён); qr-image — отдельным файлом. +app.MapTelegramEndpoints(); +app.MapTelegramQrImageEndpoint(); +// Эндпоинты /api/discovery (план Task 19, Ruling 11): задачи поиска/кандидаты/чёрный список/лог/generate-keywords +// (DiscoveryEndpoints, 1:1 api-map §3.8) — модуль Discovery зарегистрирован AddDiscoveryModule выше. +app.MapDiscoveryEndpoints(); +// gRPC-ингресс и его health освобождены от HTTP-политик rate limiter (план Task 11, Ruling 5): лимит +// входящего потока считает IngressRateLimitInterceptor по tenant-id из metadata (иначе общее окно на IP +// telegram-service резало бы весь ингресс раньше интерцептора); health — инфраструктурный liveness. +app.MapGrpcService().DisableRateLimiting(); +app.MapGrpcHealthChecksService().DisableRateLimiting(); + +app.Run(); + +// ── Kestrel: основной HTTP/1.1-эндпоинт из URL-конфигурации (явные Listen заменяют URL-биндинг) ── + +// Биндит основной HTTP-эндпоинт(ы) из ключа "urls" (--urls/ASPNETCORE_URLS/launchSettings): без явных +// адресов используется фолбэк defaultHttpUrl (как дефолт ASP.NET Core). Схема https — сертификат из +// стандартной конфигурации Kestrel (UseHttps без аргументов), как при URL-биндинге. +static void BindMainHttpEndpoints(KestrelServerOptions kestrel, string? urlsConfig) +{ + List addresses = ParseHttpAddresses(urlsConfig); + if (addresses.Count == 0) + { + addresses.Add(new Uri(defaultHttpUrl)); + } + + foreach (Uri address in addresses) + { + // Dev-запуски используют loopback («localhost»/127.0.0.1) — ListenLocalhost; «0.0.0.0»/«*»/«+»/«::» + // (compose/host) — все интерфейсы. Иных хостов в конфигурации нет (URL-хосты не биндятся по имени). + bool isHttps = string.Equals(address.Scheme, Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase); + string host = address.Host.Trim('[', ']'); + if (host is "0.0.0.0" or "*" or "+" or "::") + { + kestrel.Listen(IPAddress.Any, address.Port, listen => + { + if (isHttps) + { + listen.UseHttps(); + } + }); + } + else if (isHttps) + { + kestrel.ListenLocalhost(address.Port, listen => listen.UseHttps()); + } + else + { + kestrel.ListenLocalhost(address.Port); + } + } +} + +// Разбирает адреса конфигурации "urls" (http/https, разделитель «;»); невалидные/чужие схемы пропускаются. +static List ParseHttpAddresses(string? urlsConfig) +{ + var addresses = new List(); + if (string.IsNullOrWhiteSpace(urlsConfig)) + { + return addresses; + } + + foreach (string part in urlsConfig.Split(';', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)) + { + if (Uri.TryCreate(part, UriKind.Absolute, out Uri? uri) + && (uri.Scheme == Uri.UriSchemeHttp || uri.Scheme == Uri.UriSchemeHttps)) + { + addresses.Add(uri); + } + } + + return addresses; +} + +// Парсит порт из env-строки; пустое/нечисловое значение — null (фолбэк дефолта). +static int? ParsePort(string? rawValue) + => int.TryParse(rawValue, out int parsedPort) ? parsedPort : null; + +// Дефолт-бюджет нового тенанта из env DEAL_DEFAULT_AI_BUDGET (Ruling 3, Task 8): нечисловое/неположительное +// значение (пустая переменная, опечатка) — константа модуля TokenBudgetDefaults.DefaultBudgetTokens. Период +// всегда month — оператор меняет бюджет/период позже через PATCH лимита (Task 10). +long ResolveDefaultAiBudget(IConfiguration configuration) +{ + string? rawValue = configuration[defaultAiBudgetEnvKey]; + return long.TryParse(rawValue, out long parsedBudget) && parsedBudget > 0 + ? parsedBudget + : TokenBudgetDefaults.DefaultBudgetTokens; +} + +public partial class Program +{ + /// + /// Строит опции UseForwardedHeaders из ForwardedHeadersConfig (план Task 12; замечание ревью + /// T4/T11): обрабатываются X-Forwarded-For/X-Forwarded-Proto ровно одного доверенного hop'а. Списки + /// доверия — строго из конфига KnownProxies/KnownNetworks (appsettings-дефолт — loopback); невалидный + /// IP/CIDR — InvalidOperationException (fail-fast: опечатка в настройке доверия не должна молча + /// отключать обработку). Публичный: unit-тесты опций (ForwardedHeadersHttpTests). + /// + /// Секция ForwardedHeaders конфигурации. + /// Опции для app.UseForwardedHeaders. + public static ForwardedHeadersOptions BuildForwardedHeadersOptions(ForwardedHeadersConfig config) + { + ArgumentNullException.ThrowIfNull(config); + var options = new ForwardedHeadersOptions + { + // Адрес клиента и схема (Origin-проверка «своего» origin за Caddy требует https из + // X-Forwarded-Proto). X-Forwarded-Host не нужен: Caddy транслирует Host без перезаписи. + ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto, + // Между клиентом и core ровно один прокси (Caddy): читаем ближайший к нам элемент цепочки. + ForwardLimit = 1, + }; + // Доверие — только явный конфиг + loopback-фолбэк ниже: очищаем дефолты конструктора, чтобы + // перечисление в KnownProxies/KnownIPNetworks было единственным источником истины. + options.KnownProxies.Clear(); + options.KnownIPNetworks.Clear(); + foreach (string proxy in config.KnownProxies) + { + if (!IPAddress.TryParse(proxy, out IPAddress? address)) + { + throw new InvalidOperationException( + $"Невалидный IP доверенного прокси в ForwardedHeaders:KnownProxies: \"{proxy}\"."); + } + + options.KnownProxies.Add(address); + } + + foreach (string network in config.KnownNetworks) + { + if (!TryParseCidr(network, out System.Net.IPNetwork parsedNetwork)) + { + throw new InvalidOperationException( + $"Невалидная подсеть доверенного прокси в ForwardedHeaders:KnownNetworks: \"{network}\" " + + "(ожидается CIDR, напр. 172.16.0.0/12)."); + } + + options.KnownIPNetworks.Add(parsedNetwork); + } + + if (options.KnownProxies.Count == 0 && options.KnownIPNetworks.Count == 0) + { + // Пустые списки KnownProxies/KnownIPNetworks у ForwardedHeadersMiddleware означают «доверять + // ЛЮБОМУ клиенту» (спуфинг XFF) — пустоту до middleware не допускаем. Дефолт без явного + // конфига — loopback (dev-прокси на хосте: vite/локальный Caddy, см. appsettings); PROD + // перечисляет Caddy адресами/подсетями в конфиге. + options.KnownProxies.Add(IPAddress.Loopback); + options.KnownProxies.Add(IPAddress.IPv6Loopback); + } + + return options; + } + + // Разбирает CIDR-запись подсети ("127.0.0.0/8", "2001:db8::/32"); без префикса — вся + // подсеть одного адреса (IPv4 — /32, IPv6 — /128). + // value: Строка конфига (ForwardedHeaders:KnownNetworks). + // network: Разобранная подсеть при успехе. + // Возвращает: true — запись валидна. + private static bool TryParseCidr(string value, out System.Net.IPNetwork network) + { + network = default; + int slashIndex = value.IndexOf('/'); + string addressPart = slashIndex >= 0 ? value[..slashIndex] : value; + if (!IPAddress.TryParse(addressPart, out IPAddress? address)) + { + return false; + } + + int maxPrefixLength = address.AddressFamily == AddressFamily.InterNetwork ? 32 : 128; + int prefixLength = maxPrefixLength; + if (slashIndex >= 0) + { + string lengthPart = value[(slashIndex + 1)..]; + if (!int.TryParse(lengthPart, out prefixLength) || prefixLength < 0 || prefixLength > maxPrefixLength) + { + return false; + } + } + + network = new System.Net.IPNetwork(address, prefixLength); + return true; + } +} diff --git a/src/core/Deal.Api/RatesRefreshScheduler.cs b/src/core/Deal.Api/RatesRefreshScheduler.cs index 3c182b9..9abdb3d 100644 --- a/src/core/Deal.Api/RatesRefreshScheduler.cs +++ b/src/core/Deal.Api/RatesRefreshScheduler.cs @@ -1,4 +1,7 @@ -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Api; diff --git a/src/core/Deal.Api/Telegram/TelegramIngressService.cs b/src/core/Deal.Api/Telegram/TelegramIngressService.cs index 7d356b5..73ec94c 100644 --- a/src/core/Deal.Api/Telegram/TelegramIngressService.cs +++ b/src/core/Deal.Api/Telegram/TelegramIngressService.cs @@ -1,417 +1,424 @@ -using System.Text.Json; -using Deal.Api.Events; -using Deal.Contracts.Integrations.Models; -using Deal.Grpc.Telegram; -using Deal.Modules.Pipeline.Application; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Settings.Application; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Telegram.Application; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Tenants; -using Grpc.Core; - -namespace Deal.Api.Telegram; - -/// -/// gRPC-сервер входящего потока telegram-service → ядро (план Task 12, L361–377; Ruling 1/7). -/// -/// Реализация серверной стороны Deal.Grpc.Telegram.IngressService (telegram.proto, L380–395): -/// PushMessage — новое/догоняющее сообщение мониторящегося диалога в очередь пайплайна -/// (, тот же контракт, что приём сообщений пайплайна) в схеме тенанта -/// + превью (DialogsService.SavePreview: TgMessages + «последнее сообщение» каталога, Ruling 7); -/// SyncDialogs — применение каталога диалогов (DialogsService.SyncFromTelegram) и ответ со списком -/// monitored id (зеркало сервиса); ReportStatus — статус аккаунта в KV (tgStatus/tgAccount) + SSE -/// system_status/тосты на переходах фаз. -/// -/// -/// Tenant-id берётся ТОЛЬКО из gRPC-metadata (полю в теле не доверяем — Ruling 1), принадлежность -/// подтверждается реестром тенантов (public.tenants), затем для работы открывается собственный scope -/// с ITenantContext.SetTenant (эталон PipelineWorkerScheduler, L169–213): tenant-scoped адаптеры -/// (PipelineStore/SettingsStore) строятся от схемы тенанта. Неизвестный тенант/сбой схемы — RPC не падает: -/// ответ не-принято (accepted=false / ok=false, план Task 12) + лог аудита (Ruling 13); недоступный сервис -/// догоняет упущенное realtime-sweep (контракт README). -/// -/// Полная синхронизация каталога (применение entries к таблице Dialogs, ответ = список monitored id) — -/// модуль Deal.Modules.Telegram (план Task 13): DialogsService.SyncFromTelegram (upsert/удаление, авто- -/// мониторинг новых по autoMonitorNew), превью сообщений — DialogsService.SavePreview (PushMessage). -/// -/// -public sealed class TelegramIngressService( - IServiceScopeFactory scopeFactory, - SseBroker broker, - ILogger logger) : IngressService.IngressServiceBase -{ - /// - /// Ключ gRPC-metadata с id тенанта (единственный источник принадлежности — Ruling 1). - /// - public const string TenantIdMetadataKey = "tenant-id"; - - // Тип SSE-события статуса Telegram (фронт по нему перечитывает GET /api/tg/status, Ruling 7). - private const string SystemStatusEventType = "system_status"; - - // Тип SSE-события тоста (Ruling 5; api.js L79 слушает 'toast'). - private const string ToastEventType = "toast"; - - // Текст тоста подключения (Ruling 7, 1:1 с прототипом). - private const string ConnectedToastText = "Telegram подключён, сессия сохранена"; - - // Текст тоста отключения (Ruling 7, 1:1 с прототипом). - private const string DisconnectedToastText = "Telegram отключён"; - - // Иконка тоста подключения (из набора Icon.vue фронта). - private const string ConnectedToastIcon = "send"; - - // Иконка тоста отключения (из набора Icon.vue фронта). - private const string DisconnectedToastIcon = "logout"; - - // Деталь отказа: metadata tenant-id отсутствует (UNAUTHENTICATED, README src/contracts). - private const string MissingTenantIdDetail = "tenant-id отсутствует в metadata"; - - // Опции JSON KV-статуса: camelCase (1:1 с wire-именами) + терпимость регистра при чтении. - private static readonly JsonSerializerOptions StatusJsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - }; - - /// - /// PushMessage — сообщение диалога в очередь пайплайна тенанта + превью (PushMessageRequest, Ruling 7). - /// - /// Дубль dialog_id+msg_id уже в очереди — duplicate=true, очередь не растёт (гвард - /// PipelineIngestService). Пустой текст/диалог — no-op приёма (accepted=false, контракт proto). - /// После постановки в очередь пишется превью (DialogsService.SavePreview: строка TgMessages - /// «m_<dialog>_<msg>» + «последнее сообщение» каталога — 1:1 _on_message python L270–274); - /// сбой превью не влияет на приём (accepted определён очередью, лог дебага). - /// Неизвестный тенант или сбой схемы/БД — не-принято (accepted=false) без исключения RPC. - /// Сообщение из потока telegram-service. - /// Контекст вызова (metadata tenant-id + service-token). - /// accepted — сообщение принято (либо дубль), duplicate — уже было в очереди. - public override async Task PushMessage(PushMessageRequest request, ServerCallContext context) - { - TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false); - if (tenant is null) - { - return new PushMessageReply(); - } - - await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope(); - ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); - try - { - // Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта. - tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); - PipelineIngestService ingest = tenantScope.ServiceProvider.GetRequiredService(); - - PipelineIngestResultDto result = await ingest.EnqueueAsync( - new QueuedMessage - { - DialogId = request.DialogId, - ChannelName = request.ChannelName, - ChannelHandle = request.ChannelHandle, - ChannelHue = request.ChannelHue, - MsgId = request.HasMsgId ? request.MsgId : null, - Text = request.Text, - MsgAtMs = request.HasMsgAt ? request.MsgAt : null, - }, - context.CancellationToken).ConfigureAwait(false); - - await SavePreviewSafelyAsync(tenantScope, tenant, request, context.CancellationToken).ConfigureAwait(false); - - logger.LogInformation( - "Аудит: PushMessage {TenantId} диалог {DialogId} msg {MsgId} → {Outcome}", - tenant.Id, - request.DialogId, - request.HasMsgId ? request.MsgId.ToString() : "-", - result.Duplicate ? "duplicate" : result.Id is null ? "no-op" : "queued"); - - return new PushMessageReply - { - Accepted = result.Id is not null || result.Duplicate, - Duplicate = result.Duplicate, - }; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - // Сбой схемы/БД тенанта (напр. схема ещё не провижинена): RPC не падает — reply not-accepted - // (план Task 12), упущенное сообщение при необходимости догонит realtime-sweep сервиса. - logger.LogWarning(exception, "Аудит: PushMessage {TenantId} → не принято (сбой схемы/БД)", tenant.Id); - return new PushMessageReply(); - } - finally - { - tenantContext.Reset(); - } - } - - /// - /// SyncDialogs — синхронизация каталога диалогов аккаунта (Ruling 7, L386–390). - /// - /// - /// Модуль Deal.Modules.Telegram (план Task 13) применяет entries к таблице Dialogs - /// (DialogsService.SyncFromTelegram: upsert + удаление отсутствующих; авто-мониторинг новых — по - /// настройке autoMonitorNew). Ответ несёт актуальный список monitored id — по нему telegram-service - /// держит своё зеркало мониторинга в памяти (обновляется ответом SyncDialogs и командой SetMonitor, - /// Ruling 7) и фильтрует события realtime. - /// - /// Актуальный каталог диалогов (entries). - /// Контекст вызова. - /// monitored_ids — диалоги с включённым мониторингом после применения каталога. - public override async Task SyncDialogs(SyncDialogsRequest request, ServerCallContext context) - { - TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false); - if (tenant is null) - { - return new SyncDialogsReply(); - } - - await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope(); - ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); - try - { - tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); - DialogsService dialogs = tenantScope.ServiceProvider.GetRequiredService(); - - List entries = new(request.Entries.Count); - foreach (DialogEntry entry in request.Entries) - { - entries.Add(new TelegramDialogEntryDto(entry.Id, entry.Name, entry.Username, entry.Kind, entry.Hue)); - } - - int synced = await dialogs.SyncFromTelegramAsync(entries, context.CancellationToken).ConfigureAwait(false); - IReadOnlyCollection monitoredIds = - await dialogs.ListMonitoredIdsAsync(context.CancellationToken).ConfigureAwait(false); - - logger.LogInformation( - "Аудит: SyncDialogs {TenantId}: каталог {Count} → применено {Synced}, monitored {Monitored}", - tenant.Id, - request.Entries.Count, - synced, - monitoredIds.Count); - - var reply = new SyncDialogsReply(); - reply.MonitoredIds.AddRange(monitoredIds); - return reply; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - logger.LogWarning(exception, "Аудит: SyncDialogs {TenantId} → каталог не применён (сбой схемы/БД)", tenant.Id); - return new SyncDialogsReply(); - } - finally - { - tenantContext.Reset(); - } - } - - /// - /// ReportStatus — статус аккаунта в KV + SSE system_status/тосты на переходах фаз (Ruling 7). - /// - /// KV tgStatus (снимок без account) и tgAccount (JSON-строка) пишутся в схему тенанта; - /// system_status публикуется на каждый репорт (фронт перечитывает /api/tg/status), тосты — только на - /// переходы connected: false→true «Telegram подключён, сессия сохранена», true→false «Telegram отключён» - /// (сервис шлёт статус по событию и heartbeat'ом — без гарда переходов тосты дублировались бы). - /// Неизвестный тенант/сбой схемы — ok=false без исключения RPC (план Task 12). - /// Статус аккаунта из _publish_status прототипа. - /// Контекст вызова. - /// ok — статус принят и сохранён. - public override async Task ReportStatus(ReportStatusRequest request, ServerCallContext context) - { - TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false); - if (tenant is null) - { - return new ReportStatusReply(); - } - - await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope(); - ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); - try - { - tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); - ISettingsStore settings = tenantScope.ServiceProvider.GetRequiredService(); - - TgReportedStatus current = ToReportedStatus(request); - TgReportedStatus? previous = await ReadPreviousStatusAsync(settings, context.CancellationToken).ConfigureAwait(false); - - // SSE до записи KV: канал тенанта обновляется и при сбое записи (следующий репорт перепишет KV). - PublishStatusEvents(tenant.Id, previous, current); - - await settings.SetAsync(SettingsKeys.TgStatus, ToJson(current), context.CancellationToken).ConfigureAwait(false); - await settings.SetAsync(SettingsKeys.TgAccount, JsonSerializer.Serialize(request.Account), context.CancellationToken).ConfigureAwait(false); - - logger.LogInformation( - "Аудит: ReportStatus {TenantId} → фаза {Phase}, connected {Connected}", - tenant.Id, - request.Phase, - request.Connected); - return new ReportStatusReply { Ok = true }; - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - logger.LogWarning(exception, "Аудит: ReportStatus {TenantId} → не сохранён (сбой схемы/БД)", tenant.Id); - return new ReportStatusReply(); - } - finally - { - tenantContext.Reset(); - } - } - - // Разрешает тенанта запроса: metadata tenant-id → реестр public.tenants. - // Отсутствующий/пустой tenant-id — RPC-отказ UNAUTHENTICATED (README: tenant-id обязателен). - // Id не Guid либо записи нет в реестре — неизвестный тенант: лог аудита и null (RPC отвечает не-принято, - // план Task 12: «для несуществующего тенанта не падает»). - // context: Контекст вызова. - // Возвращает: Запись тенанта реестра либо null (тенант неизвестен). - private async Task ResolveTenantAsync(ServerCallContext context) - { - string tenantId = RequireTenantIdMetadata(context); - if (!Guid.TryParse(tenantId, out Guid tenantGuid)) - { - logger.LogWarning("Аудит: ингресс {Action} → тенант {TenantId} неизвестен (id не Guid)", context.Method, tenantId); - return null; - } - - await using AsyncServiceScope registryScope = scopeFactory.CreateAsyncScope(); - ITenantRepository repository = registryScope.ServiceProvider.GetRequiredService(); - TenantRecordDto? tenant = await repository.FindByIdAsync(tenantGuid, context.CancellationToken).ConfigureAwait(false); - if (tenant is null) - { - logger.LogWarning("Аудит: ингресс {Action} → тенант {TenantId} неизвестен (нет в реестре)", context.Method, tenantId); - } - - return tenant; - } - - // Читает tenant-id из metadata (обязателен; отсутствие — UNAUTHENTICATED, README). - // context: Контекст вызова. - // Возвращает: Значение tenant-id. - private static string RequireTenantIdMetadata(ServerCallContext context) - { - string? tenantId = context.RequestHeaders.GetValue(TenantIdMetadataKey); - if (string.IsNullOrWhiteSpace(tenantId)) - { - throw new RpcException(new Status(StatusCode.Unauthenticated, MissingTenantIdDetail)); - } - - return tenantId; - } - - // Пишет превью принятого сообщения (TgMessages + «последнее сообщение» каталога) без влияния на приём. - // Ruling 7: PushMessage → EnqueueAsync + превью. Сбой превью (нет таблиц/строки каталога и т.п.) - // не роняет RPC и не меняет accepted — очередь уже записана, упущенное догонит realtime-sweep (как - // python: обновление last_text после enqueue в том же обработчике, ошибка не отменяет приём). - // tenantScope: Scope тенанта (TenantDbContext построен на схеме тенанта). - // tenant: Тенант канала (для лога аудита). - // request: Сообщение PushMessage. - // ct: Токен отмены. - private async Task SavePreviewSafelyAsync( - AsyncServiceScope tenantScope, TenantRecordDto tenant, PushMessageRequest request, CancellationToken ct) - { - try - { - DialogsService dialogs = tenantScope.ServiceProvider.GetRequiredService(); - DateTimeOffset? msgAt = request.HasMsgAt ? DateTimeOffset.FromUnixTimeMilliseconds(request.MsgAt) : null; - await dialogs.SavePreviewAsync( - request.DialogId, - request.HasMsgId ? request.MsgId : null, - request.Text, - msgAt, - ct).ConfigureAwait(false); - } - catch (OperationCanceledException) - { - throw; - } - catch (Exception exception) - { - // Превью — вторичная запись: приём не затронут (лог дебага, не ошибка RPC). - logger.LogDebug(exception, "PushMessage {TenantId}: превью не сохранено (приём не затронут)", tenant.Id); - } - } - - // Публикует SSE system_status (каждый репорт) и тосты на переходах connected. - // tenantId: Тенант канала (реестровый Guid). - // previous: Предыдущий снимок из KV (null — первый репорт). - // current: Текущий снимок репорта. - private void PublishStatusEvents(Guid tenantId, TgReportedStatus? previous, TgReportedStatus current) - { - broker.Publish(tenantId, SystemStatusEventType, current); - if (previous is null) - { - // Первый репорт после старта сервиса: переходов нет, статус фронт получит по system_status. - return; - } - - if (!previous.Connected && current.Connected) - { - PublishToast(tenantId, ConnectedToastText, ConnectedToastIcon); - } - else if (previous.Connected && !current.Connected) - { - PublishToast(tenantId, DisconnectedToastText, DisconnectedToastIcon); - } - } - - // Публикует SSE-тост в канал тенанта (без подписчиков — no-op, Ruling 5). - // tenantId: Тенант-получатель. - // text: Текст тоста. - // icon: Иконка тоста (набор Icon.vue фронта). - private void PublishToast(Guid tenantId, string text, string icon) - { - broker.Publish(tenantId, ToastEventType, new { text, icon }); - } - - // Снимок предыдущего статуса из KV tgStatus (нет записи/битый JSON — null). - // settings: KV-хранилище настроек схемы тенанта. - // ct: Токен отмены. - // Возвращает: Предыдущий снимок либо null. - private async Task ReadPreviousStatusAsync(ISettingsStore settings, CancellationToken ct) - { - SettingValue? stored = await settings.GetAsync(SettingsKeys.TgStatus, ct).ConfigureAwait(false); - if (stored is null) - { - return null; - } - - try - { - return JsonSerializer.Deserialize(stored.ValueJson, StatusJsonOptions); - } - catch (JsonException exception) - { - logger.LogWarning(exception, "Аудит: KV tgStatus повреждён — переходы фаз не определяются"); - return null; - } - } - - // Маппит запрос ReportStatus в снимок KV (account живёт отдельным ключом tgAccount). - // request: Запрос ReportStatus. - // Возвращает: Снимок статуса. - private static TgReportedStatus ToReportedStatus(ReportStatusRequest request) => new() - { - Phase = request.Phase, - Connected = request.Connected, - Listener = request.Listener, - Error = request.HasError ? request.Error : null, - QrUrl = request.HasQrUrl ? request.QrUrl : null, - }; - - // Сериализует снимок в JSON (camelCase, конвенция value_json). - // status: Снимок статуса. - // Возвращает: JSON-строка. - private static string ToJson(TgReportedStatus status) => JsonSerializer.Serialize(status, StatusJsonOptions); -} +using System.Text.Json; +using Deal.Api.Events; +using Deal.Contracts.Integrations.Models; +using Deal.Grpc.Telegram; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Telegram.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; +using Deal.SharedKernel.Tenants; +using Grpc.Core; + +namespace Deal.Api.Telegram; + +/// +/// gRPC-сервер входящего потока telegram-service → ядро (план Task 12, L361–377; Ruling 1/7). +/// +/// Реализация серверной стороны Deal.Grpc.Telegram.IngressService (telegram.proto, L380–395): +/// PushMessage — новое/догоняющее сообщение мониторящегося диалога в очередь пайплайна +/// (, тот же контракт, что приём сообщений пайплайна) в схеме тенанта +/// + превью (DialogsService.SavePreview: TgMessages + «последнее сообщение» каталога, Ruling 7); +/// SyncDialogs — применение каталога диалогов (DialogsService.SyncFromTelegram) и ответ со списком +/// monitored id (зеркало сервиса); ReportStatus — статус аккаунта в KV (tgStatus/tgAccount) + SSE +/// system_status/тосты на переходах фаз. +/// +/// +/// Tenant-id берётся ТОЛЬКО из gRPC-metadata (полю в теле не доверяем — Ruling 1), принадлежность +/// подтверждается реестром тенантов (public.tenants), затем для работы открывается собственный scope +/// с ITenantContext.SetTenant (эталон PipelineWorkerScheduler, L169–213): tenant-scoped адаптеры +/// (PipelineStore/SettingsStore) строятся от схемы тенанта. Неизвестный тенант/сбой схемы — RPC не падает: +/// ответ не-принято (accepted=false / ok=false, план Task 12) + лог аудита (Ruling 13); недоступный сервис +/// догоняет упущенное realtime-sweep (контракт README). +/// +/// Полная синхронизация каталога (применение entries к таблице Dialogs, ответ = список monitored id) — +/// модуль Deal.Modules.Telegram (план Task 13): DialogsService.SyncFromTelegram (upsert/удаление, авто- +/// мониторинг новых по autoMonitorNew), превью сообщений — DialogsService.SavePreview (PushMessage). +/// +/// +public sealed class TelegramIngressService( + IServiceScopeFactory scopeFactory, + SseBroker broker, + ILogger logger) : IngressService.IngressServiceBase +{ + /// + /// Ключ gRPC-metadata с id тенанта (единственный источник принадлежности — Ruling 1). + /// + public const string TenantIdMetadataKey = "tenant-id"; + + // Тип SSE-события статуса Telegram (фронт по нему перечитывает GET /api/tg/status, Ruling 7). + private const string SystemStatusEventType = "system_status"; + + // Тип SSE-события тоста (Ruling 5; api.js L79 слушает 'toast'). + private const string ToastEventType = "toast"; + + // Текст тоста подключения (Ruling 7, 1:1 с прототипом). + private const string ConnectedToastText = "Telegram подключён, сессия сохранена"; + + // Текст тоста отключения (Ruling 7, 1:1 с прототипом). + private const string DisconnectedToastText = "Telegram отключён"; + + // Иконка тоста подключения (из набора Icon.vue фронта). + private const string ConnectedToastIcon = "send"; + + // Иконка тоста отключения (из набора Icon.vue фронта). + private const string DisconnectedToastIcon = "logout"; + + // Деталь отказа: metadata tenant-id отсутствует (UNAUTHENTICATED, README src/contracts). + private const string MissingTenantIdDetail = "tenant-id отсутствует в metadata"; + + // Опции JSON KV-статуса: camelCase (1:1 с wire-именами) + терпимость регистра при чтении. + private static readonly JsonSerializerOptions StatusJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + /// + /// PushMessage — сообщение диалога в очередь пайплайна тенанта + превью (PushMessageRequest, Ruling 7). + /// + /// Дубль dialog_id+msg_id уже в очереди — duplicate=true, очередь не растёт (гвард + /// PipelineIngestService). Пустой текст/диалог — no-op приёма (accepted=false, контракт proto). + /// После постановки в очередь пишется превью (DialogsService.SavePreview: строка TgMessages + /// «m_<dialog>_<msg>» + «последнее сообщение» каталога — 1:1 _on_message python L270–274); + /// сбой превью не влияет на приём (accepted определён очередью, лог дебага). + /// Неизвестный тенант или сбой схемы/БД — не-принято (accepted=false) без исключения RPC. + /// Сообщение из потока telegram-service. + /// Контекст вызова (metadata tenant-id + service-token). + /// accepted — сообщение принято (либо дубль), duplicate — уже было в очереди. + public override async Task PushMessage(PushMessageRequest request, ServerCallContext context) + { + TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false); + if (tenant is null) + { + return new PushMessageReply(); + } + + await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope(); + ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); + try + { + // Resolve ПОСЛЕ SetTenant: TenantDbContext (и его адаптеры) строятся от схемы текущего тенанта. + tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); + PipelineIngestService ingest = tenantScope.ServiceProvider.GetRequiredService(); + + PipelineIngestResultDto result = await ingest.EnqueueAsync( + new QueuedMessage + { + DialogId = request.DialogId, + ChannelName = request.ChannelName, + ChannelHandle = request.ChannelHandle, + ChannelHue = request.ChannelHue, + MsgId = request.HasMsgId ? request.MsgId : null, + Text = request.Text, + MsgAtMs = request.HasMsgAt ? request.MsgAt : null, + }, + context.CancellationToken).ConfigureAwait(false); + + await SavePreviewSafelyAsync(tenantScope, tenant, request, context.CancellationToken).ConfigureAwait(false); + + logger.LogInformation( + "Аудит: PushMessage {TenantId} диалог {DialogId} msg {MsgId} → {Outcome}", + tenant.Id, + request.DialogId, + request.HasMsgId ? request.MsgId.ToString() : "-", + result.Duplicate ? "duplicate" : result.Id is null ? "no-op" : "queued"); + + return new PushMessageReply + { + Accepted = result.Id is not null || result.Duplicate, + Duplicate = result.Duplicate, + }; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + // Сбой схемы/БД тенанта (напр. схема ещё не провижинена): RPC не падает — reply not-accepted + // (план Task 12), упущенное сообщение при необходимости догонит realtime-sweep сервиса. + logger.LogWarning(exception, "Аудит: PushMessage {TenantId} → не принято (сбой схемы/БД)", tenant.Id); + return new PushMessageReply(); + } + finally + { + tenantContext.Reset(); + } + } + + /// + /// SyncDialogs — синхронизация каталога диалогов аккаунта (Ruling 7, L386–390). + /// + /// + /// Модуль Deal.Modules.Telegram (план Task 13) применяет entries к таблице Dialogs + /// (DialogsService.SyncFromTelegram: upsert + удаление отсутствующих; авто-мониторинг новых — по + /// настройке autoMonitorNew). Ответ несёт актуальный список monitored id — по нему telegram-service + /// держит своё зеркало мониторинга в памяти (обновляется ответом SyncDialogs и командой SetMonitor, + /// Ruling 7) и фильтрует события realtime. + /// + /// Актуальный каталог диалогов (entries). + /// Контекст вызова. + /// monitored_ids — диалоги с включённым мониторингом после применения каталога. + public override async Task SyncDialogs(SyncDialogsRequest request, ServerCallContext context) + { + TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false); + if (tenant is null) + { + return new SyncDialogsReply(); + } + + await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope(); + ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); + try + { + tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); + DialogsService dialogs = tenantScope.ServiceProvider.GetRequiredService(); + + List entries = new(request.Entries.Count); + foreach (DialogEntry entry in request.Entries) + { + entries.Add(new TelegramDialogEntryDto(entry.Id, entry.Name, entry.Username, entry.Kind, entry.Hue)); + } + + int synced = await dialogs.SyncFromTelegramAsync(entries, context.CancellationToken).ConfigureAwait(false); + IReadOnlyCollection monitoredIds = + await dialogs.ListMonitoredIdsAsync(context.CancellationToken).ConfigureAwait(false); + + logger.LogInformation( + "Аудит: SyncDialogs {TenantId}: каталог {Count} → применено {Synced}, monitored {Monitored}", + tenant.Id, + request.Entries.Count, + synced, + monitoredIds.Count); + + var reply = new SyncDialogsReply(); + reply.MonitoredIds.AddRange(monitoredIds); + return reply; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Аудит: SyncDialogs {TenantId} → каталог не применён (сбой схемы/БД)", tenant.Id); + return new SyncDialogsReply(); + } + finally + { + tenantContext.Reset(); + } + } + + /// + /// ReportStatus — статус аккаунта в KV + SSE system_status/тосты на переходах фаз (Ruling 7). + /// + /// KV tgStatus (снимок без account) и tgAccount (JSON-строка) пишутся в схему тенанта; + /// system_status публикуется на каждый репорт (фронт перечитывает /api/tg/status), тосты — только на + /// переходы connected: false→true «Telegram подключён, сессия сохранена», true→false «Telegram отключён» + /// (сервис шлёт статус по событию и heartbeat'ом — без гарда переходов тосты дублировались бы). + /// Неизвестный тенант/сбой схемы — ok=false без исключения RPC (план Task 12). + /// Статус аккаунта из _publish_status прототипа. + /// Контекст вызова. + /// ok — статус принят и сохранён. + public override async Task ReportStatus(ReportStatusRequest request, ServerCallContext context) + { + TenantRecordDto? tenant = await ResolveTenantAsync(context).ConfigureAwait(false); + if (tenant is null) + { + return new ReportStatusReply(); + } + + await using AsyncServiceScope tenantScope = scopeFactory.CreateAsyncScope(); + ITenantContext tenantContext = tenantScope.ServiceProvider.GetRequiredService(); + try + { + tenantContext.SetTenant(new TenantId(tenant.Id.ToString("N"))); + ISettingsStore settings = tenantScope.ServiceProvider.GetRequiredService(); + + TgReportedStatus current = ToReportedStatus(request); + TgReportedStatus? previous = await ReadPreviousStatusAsync(settings, context.CancellationToken).ConfigureAwait(false); + + // SSE до записи KV: канал тенанта обновляется и при сбое записи (следующий репорт перепишет KV). + PublishStatusEvents(tenant.Id, previous, current); + + await settings.SetAsync(SettingsKeys.TgStatus, ToJson(current), context.CancellationToken).ConfigureAwait(false); + await settings.SetAsync(SettingsKeys.TgAccount, JsonSerializer.Serialize(request.Account), context.CancellationToken).ConfigureAwait(false); + + logger.LogInformation( + "Аудит: ReportStatus {TenantId} → фаза {Phase}, connected {Connected}", + tenant.Id, + request.Phase, + request.Connected); + return new ReportStatusReply { Ok = true }; + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + logger.LogWarning(exception, "Аудит: ReportStatus {TenantId} → не сохранён (сбой схемы/БД)", tenant.Id); + return new ReportStatusReply(); + } + finally + { + tenantContext.Reset(); + } + } + + // Разрешает тенанта запроса: metadata tenant-id → реестр public.tenants. + // Отсутствующий/пустой tenant-id — RPC-отказ UNAUTHENTICATED (README: tenant-id обязателен). + // Id не Guid либо записи нет в реестре — неизвестный тенант: лог аудита и null (RPC отвечает не-принято, + // план Task 12: «для несуществующего тенанта не падает»). + // context: Контекст вызова. + // Возвращает: Запись тенанта реестра либо null (тенант неизвестен). + private async Task ResolveTenantAsync(ServerCallContext context) + { + string tenantId = RequireTenantIdMetadata(context); + if (!Guid.TryParse(tenantId, out Guid tenantGuid)) + { + logger.LogWarning("Аудит: ингресс {Action} → тенант {TenantId} неизвестен (id не Guid)", context.Method, tenantId); + return null; + } + + await using AsyncServiceScope registryScope = scopeFactory.CreateAsyncScope(); + ITenantRepository repository = registryScope.ServiceProvider.GetRequiredService(); + TenantRecordDto? tenant = await repository.FindByIdAsync(tenantGuid, context.CancellationToken).ConfigureAwait(false); + if (tenant is null) + { + logger.LogWarning("Аудит: ингресс {Action} → тенант {TenantId} неизвестен (нет в реестре)", context.Method, tenantId); + } + + return tenant; + } + + // Читает tenant-id из metadata (обязателен; отсутствие — UNAUTHENTICATED, README). + // context: Контекст вызова. + // Возвращает: Значение tenant-id. + private static string RequireTenantIdMetadata(ServerCallContext context) + { + string? tenantId = context.RequestHeaders.GetValue(TenantIdMetadataKey); + if (string.IsNullOrWhiteSpace(tenantId)) + { + throw new RpcException(new Status(StatusCode.Unauthenticated, MissingTenantIdDetail)); + } + + return tenantId; + } + + // Пишет превью принятого сообщения (TgMessages + «последнее сообщение» каталога) без влияния на приём. + // Ruling 7: PushMessage → EnqueueAsync + превью. Сбой превью (нет таблиц/строки каталога и т.п.) + // не роняет RPC и не меняет accepted — очередь уже записана, упущенное догонит realtime-sweep (как + // python: обновление last_text после enqueue в том же обработчике, ошибка не отменяет приём). + // tenantScope: Scope тенанта (TenantDbContext построен на схеме тенанта). + // tenant: Тенант канала (для лога аудита). + // request: Сообщение PushMessage. + // ct: Токен отмены. + private async Task SavePreviewSafelyAsync( + AsyncServiceScope tenantScope, TenantRecordDto tenant, PushMessageRequest request, CancellationToken ct) + { + try + { + DialogsService dialogs = tenantScope.ServiceProvider.GetRequiredService(); + DateTimeOffset? msgAt = request.HasMsgAt ? DateTimeOffset.FromUnixTimeMilliseconds(request.MsgAt) : null; + await dialogs.SavePreviewAsync( + request.DialogId, + request.HasMsgId ? request.MsgId : null, + request.Text, + msgAt, + ct).ConfigureAwait(false); + } + catch (OperationCanceledException) + { + throw; + } + catch (Exception exception) + { + // Превью — вторичная запись: приём не затронут (лог дебага, не ошибка RPC). + logger.LogDebug(exception, "PushMessage {TenantId}: превью не сохранено (приём не затронут)", tenant.Id); + } + } + + // Публикует SSE system_status (каждый репорт) и тосты на переходах connected. + // tenantId: Тенант канала (реестровый Guid). + // previous: Предыдущий снимок из KV (null — первый репорт). + // current: Текущий снимок репорта. + private void PublishStatusEvents(Guid tenantId, TgReportedStatus? previous, TgReportedStatus current) + { + broker.Publish(tenantId, SystemStatusEventType, current); + if (previous is null) + { + // Первый репорт после старта сервиса: переходов нет, статус фронт получит по system_status. + return; + } + + if (!previous.Connected && current.Connected) + { + PublishToast(tenantId, ConnectedToastText, ConnectedToastIcon); + } + else if (previous.Connected && !current.Connected) + { + PublishToast(tenantId, DisconnectedToastText, DisconnectedToastIcon); + } + } + + // Публикует SSE-тост в канал тенанта (без подписчиков — no-op, Ruling 5). + // tenantId: Тенант-получатель. + // text: Текст тоста. + // icon: Иконка тоста (набор Icon.vue фронта). + private void PublishToast(Guid tenantId, string text, string icon) + { + broker.Publish(tenantId, ToastEventType, new { text, icon }); + } + + // Снимок предыдущего статуса из KV tgStatus (нет записи/битый JSON — null). + // settings: KV-хранилище настроек схемы тенанта. + // ct: Токен отмены. + // Возвращает: Предыдущий снимок либо null. + private async Task ReadPreviousStatusAsync(ISettingsStore settings, CancellationToken ct) + { + SettingValue? stored = await settings.GetAsync(SettingsKeys.TgStatus, ct).ConfigureAwait(false); + if (stored is null) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(stored.ValueJson, StatusJsonOptions); + } + catch (JsonException exception) + { + logger.LogWarning(exception, "Аудит: KV tgStatus повреждён — переходы фаз не определяются"); + return null; + } + } + + // Маппит запрос ReportStatus в снимок KV (account живёт отдельным ключом tgAccount). + // request: Запрос ReportStatus. + // Возвращает: Снимок статуса. + private static TgReportedStatus ToReportedStatus(ReportStatusRequest request) => new() + { + Phase = request.Phase, + Connected = request.Connected, + Listener = request.Listener, + Error = request.HasError ? request.Error : null, + QrUrl = request.HasQrUrl ? request.QrUrl : null, + }; + + // Сериализует снимок в JSON (camelCase, конвенция value_json). + // status: Снимок статуса. + // Возвращает: JSON-строка. + private static string ToJson(TgReportedStatus status) => JsonSerializer.Serialize(status, StatusJsonOptions); +} diff --git a/src/core/Deal.Api/Telegram/TelegramKeysService.cs b/src/core/Deal.Api/Telegram/TelegramKeysService.cs index d5a88bf..d7632ea 100644 --- a/src/core/Deal.Api/Telegram/TelegramKeysService.cs +++ b/src/core/Deal.Api/Telegram/TelegramKeysService.cs @@ -1,6 +1,8 @@ using System.Text.Json; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Api.Telegram; diff --git a/src/core/Deal.Api/Telegram/TgStatusService.cs b/src/core/Deal.Api/Telegram/TgStatusService.cs index ac34e80..f1f6821 100644 --- a/src/core/Deal.Api/Telegram/TgStatusService.cs +++ b/src/core/Deal.Api/Telegram/TgStatusService.cs @@ -1,108 +1,110 @@ -using System.Text.Json; -using Deal.Contracts.Integrations; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Settings.Application; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Telegram.Application; -using Deal.Modules.Telegram.Application.Models; - -namespace Deal.Api.Telegram; - -/// -/// Сборка статуса вкладки Telegram — GET /api/tg/status (Ruling 8, api-map §4.9 L357–359). -/// -/// -/// Форма 1:1 с status() python L103–119 в терминах этапа 6: -/// -/// live-поля (phase/connected/listener/error/qrUrl) — из гейта -/// (живой telegram-service); сервис недоступен/сессии нет (RPC-отказ) → idle-форма (Ruling 8); -/// account — из KV tgAccount (источник истины — ReportStatus ингресса, Ruling 7); -/// monitored — count(Dialogs WHERE Monitor) ядра (DialogsService.ListMonitoredIds); -/// keysSet — оба глобальных ключа приложения заданы оператором (TelegramKeysService, Ruling 3, ТЗ §4.1/§8.1). -/// -/// Scoped: зависимости живут на контекстах запроса (ISettingsStore — схема тенанта, IGlobalSettingsStore — public). -/// -/// Порт-гейт telegram-service (живой статус аккаунта). -/// Сервис каталога диалогов ядра (счётчик мониторящихся). -/// KV-хранилище настроек тенанта (tgAccount). -/// Глобальные ключи приложения Telegram (keysSet). -public sealed class TgStatusService( - ITelegramGateway gateway, - DialogsService dialogs, - ISettingsStore settings, - TelegramKeysService keys) -{ - // Фаза idle-формы (аккаунт не подключён/сервис недоступен — Ruling 8). - private const string IdlePhase = "idle"; - - // Опции JSON KV-значений статуса: camelCase (как пишет ингресс) + терпимость регистра. - private static readonly JsonSerializerOptions KvJsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - }; - - /// - /// Форма GET /api/tg/status текущего тенанта (поля §4.9). - /// - /// Токен отмены. - /// Полный статус вкладки Telegram. - public async Task GetAsync(CancellationToken ct) - { - TelegramAccountStatusDto live = await ReadLiveAsync(ct).ConfigureAwait(false); - string account = await ReadAccountAsync(ct).ConfigureAwait(false); - int monitored = (await dialogs.ListMonitoredIdsAsync(ct).ConfigureAwait(false)).Count; - TgKeysSnapshot snapshot = await keys.GetAsync(ct).ConfigureAwait(false); - - return new TgStatusDto( - Phase: live.Phase, - Connected: live.Connected, - Listener: live.Listener, - Account: account, - Monitored: monitored, - KeysSet: snapshot.KeysSet, - Error: live.Error, - QrUrl: live.QrUrl); - } - - // Живой статус из гейта; сбой (сервис недоступен/нет сессии) → idle-форма (Ruling 8). - // ct: Токен отмены. - // Возвращает: Статус гейта либо idle-поля. - private async Task ReadLiveAsync(CancellationToken ct) - { - try - { - return await gateway.StatusAsync(ct).ConfigureAwait(false); - } - catch (Exception exception) when (exception is not OperationCanceledException || !ct.IsCancellationRequested) - { - // «Сервис недоступен → idle-форма» (Ruling 8): connected=false, live-поля пусты. Аккаунт/счётчики - // ядро всё равно докладывает из своего KV/БД (ниже) — как python при отключённом клиенте. - return new TelegramAccountStatusDto(IdlePhase, Connected: false, Listener: false, string.Empty, null, null); - } - } - - // Аккаунт «@username» из KV tgAccount (JSON-строка, пишет ReportStatus ингресса, Ruling 7). - // ct: Токен отмены. - // Возвращает: Аккаунт или пустая строка. - private async Task ReadAccountAsync(CancellationToken ct) - { - SettingValue? row = await settings.GetAsync(SettingsKeys.TgAccount, ct).ConfigureAwait(false); - if (row is null) - { - return string.Empty; - } - - try - { - using JsonDocument document = JsonDocument.Parse(row.ValueJson); - return document.RootElement.ValueKind == JsonValueKind.String - ? document.RootElement.GetString() ?? string.Empty - : string.Empty; - } - catch (JsonException) - { - return string.Empty; - } - } -} +using System.Text.Json; +using Deal.Contracts.Integrations; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Telegram.Application; +using Deal.Modules.Telegram.Application.Models; + +namespace Deal.Api.Telegram; + +/// +/// Сборка статуса вкладки Telegram — GET /api/tg/status (Ruling 8, api-map §4.9 L357–359). +/// +/// +/// Форма 1:1 с status() python L103–119 в терминах этапа 6: +/// +/// live-поля (phase/connected/listener/error/qrUrl) — из гейта +/// (живой telegram-service); сервис недоступен/сессии нет (RPC-отказ) → idle-форма (Ruling 8); +/// account — из KV tgAccount (источник истины — ReportStatus ингресса, Ruling 7); +/// monitored — count(Dialogs WHERE Monitor) ядра (DialogsService.ListMonitoredIds); +/// keysSet — оба глобальных ключа приложения заданы оператором (TelegramKeysService, Ruling 3, ТЗ §4.1/§8.1). +/// +/// Scoped: зависимости живут на контекстах запроса (ISettingsStore — схема тенанта, IGlobalSettingsStore — public). +/// +/// Порт-гейт telegram-service (живой статус аккаунта). +/// Сервис каталога диалогов ядра (счётчик мониторящихся). +/// KV-хранилище настроек тенанта (tgAccount). +/// Глобальные ключи приложения Telegram (keysSet). +public sealed class TgStatusService( + ITelegramGateway gateway, + DialogsService dialogs, + ISettingsStore settings, + TelegramKeysService keys) +{ + // Фаза idle-формы (аккаунт не подключён/сервис недоступен — Ruling 8). + private const string IdlePhase = "idle"; + + // Опции JSON KV-значений статуса: camelCase (как пишет ингресс) + терпимость регистра. + private static readonly JsonSerializerOptions KvJsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + /// + /// Форма GET /api/tg/status текущего тенанта (поля §4.9). + /// + /// Токен отмены. + /// Полный статус вкладки Telegram. + public async Task GetAsync(CancellationToken ct) + { + TelegramAccountStatusDto live = await ReadLiveAsync(ct).ConfigureAwait(false); + string account = await ReadAccountAsync(ct).ConfigureAwait(false); + int monitored = (await dialogs.ListMonitoredIdsAsync(ct).ConfigureAwait(false)).Count; + TgKeysSnapshot snapshot = await keys.GetAsync(ct).ConfigureAwait(false); + + return new TgStatusDto( + Phase: live.Phase, + Connected: live.Connected, + Listener: live.Listener, + Account: account, + Monitored: monitored, + KeysSet: snapshot.KeysSet, + Error: live.Error, + QrUrl: live.QrUrl); + } + + // Живой статус из гейта; сбой (сервис недоступен/нет сессии) → idle-форма (Ruling 8). + // ct: Токен отмены. + // Возвращает: Статус гейта либо idle-поля. + private async Task ReadLiveAsync(CancellationToken ct) + { + try + { + return await gateway.StatusAsync(ct).ConfigureAwait(false); + } + catch (Exception exception) when (exception is not OperationCanceledException || !ct.IsCancellationRequested) + { + // «Сервис недоступен → idle-форма» (Ruling 8): connected=false, live-поля пусты. Аккаунт/счётчики + // ядро всё равно докладывает из своего KV/БД (ниже) — как python при отключённом клиенте. + return new TelegramAccountStatusDto(IdlePhase, Connected: false, Listener: false, string.Empty, null, null); + } + } + + // Аккаунт «@username» из KV tgAccount (JSON-строка, пишет ReportStatus ингресса, Ruling 7). + // ct: Токен отмены. + // Возвращает: Аккаунт или пустая строка. + private async Task ReadAccountAsync(CancellationToken ct) + { + SettingValue? row = await settings.GetAsync(SettingsKeys.TgAccount, ct).ConfigureAwait(false); + if (row is null) + { + return string.Empty; + } + + try + { + using JsonDocument document = JsonDocument.Parse(row.ValueJson); + return document.RootElement.ValueKind == JsonValueKind.String + ? document.RootElement.GetString() ?? string.Empty + : string.Empty; + } + catch (JsonException) + { + return string.Empty; + } + } +} diff --git a/src/core/Deal.Infrastructure/Integrations/AiConnectionChecker.cs b/src/core/Deal.Infrastructure/Integrations/AiConnectionChecker.cs index 27409b2..dc48169 100644 --- a/src/core/Deal.Infrastructure/Integrations/AiConnectionChecker.cs +++ b/src/core/Deal.Infrastructure/Integrations/AiConnectionChecker.cs @@ -1,7 +1,9 @@ using System.Diagnostics.CodeAnalysis; using System.Net.Http.Headers; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Infrastructure.Integrations; diff --git a/src/core/Deal.Infrastructure/Integrations/AiProviderConfigBuilder.cs b/src/core/Deal.Infrastructure/Integrations/AiProviderConfigBuilder.cs index 8f7f8c9..dcd2e42 100644 --- a/src/core/Deal.Infrastructure/Integrations/AiProviderConfigBuilder.cs +++ b/src/core/Deal.Infrastructure/Integrations/AiProviderConfigBuilder.cs @@ -1,7 +1,9 @@ using System.Text.Json.Nodes; using Deal.Grpc.Ai; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Infrastructure.Integrations; diff --git a/src/core/Deal.Infrastructure/Integrations/BudgetedAiClassifier.cs b/src/core/Deal.Infrastructure/Integrations/BudgetedAiClassifier.cs index a816949..1898b88 100644 --- a/src/core/Deal.Infrastructure/Integrations/BudgetedAiClassifier.cs +++ b/src/core/Deal.Infrastructure/Integrations/BudgetedAiClassifier.cs @@ -1,7 +1,10 @@ using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants; using Microsoft.Extensions.Logging; diff --git a/src/core/Deal.Infrastructure/Integrations/BudgetedAiTools.cs b/src/core/Deal.Infrastructure/Integrations/BudgetedAiTools.cs index dd5746c..4ed6e6c 100644 --- a/src/core/Deal.Infrastructure/Integrations/BudgetedAiTools.cs +++ b/src/core/Deal.Infrastructure/Integrations/BudgetedAiTools.cs @@ -1,7 +1,10 @@ using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants; using Microsoft.Extensions.Logging; diff --git a/src/core/Deal.Infrastructure/Integrations/CbrRateSource.cs b/src/core/Deal.Infrastructure/Integrations/CbrRateSource.cs index baebc82..c35db28 100644 --- a/src/core/Deal.Infrastructure/Integrations/CbrRateSource.cs +++ b/src/core/Deal.Infrastructure/Integrations/CbrRateSource.cs @@ -1,6 +1,9 @@ using System.Globalization; using System.Text.Json; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; using Microsoft.Extensions.Logging; namespace Deal.Infrastructure.Integrations; diff --git a/src/core/Deal.Infrastructure/Integrations/GrpcAiClassifier.cs b/src/core/Deal.Infrastructure/Integrations/GrpcAiClassifier.cs index 0985f95..453be72 100644 --- a/src/core/Deal.Infrastructure/Integrations/GrpcAiClassifier.cs +++ b/src/core/Deal.Infrastructure/Integrations/GrpcAiClassifier.cs @@ -1,7 +1,10 @@ using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; using Deal.Grpc.Ai; -using Deal.Modules.Pipeline.Application; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; using Deal.SharedKernel.Tenants; using Grpc.Core; using Microsoft.Extensions.Logging; diff --git a/src/core/Deal.Infrastructure/Integrations/GrpcMlClient.cs b/src/core/Deal.Infrastructure/Integrations/GrpcMlClient.cs index 89e8ae8..c186914 100644 --- a/src/core/Deal.Infrastructure/Integrations/GrpcMlClient.cs +++ b/src/core/Deal.Infrastructure/Integrations/GrpcMlClient.cs @@ -2,11 +2,20 @@ using System.Text.Json; using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; using Deal.Grpc.Ml; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants; using Grpc.Core; using Microsoft.Extensions.Logging; diff --git a/src/core/Deal.Infrastructure/Integrations/LocalAiClassifier.cs b/src/core/Deal.Infrastructure/Integrations/LocalAiClassifier.cs index a9320f6..5a3abeb 100644 --- a/src/core/Deal.Infrastructure/Integrations/LocalAiClassifier.cs +++ b/src/core/Deal.Infrastructure/Integrations/LocalAiClassifier.cs @@ -1,7 +1,9 @@ using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; -using Deal.Modules.Pipeline.Application; +using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Parse; namespace Deal.Infrastructure.Integrations; diff --git a/src/core/Deal.Infrastructure/Integrations/LocalColumnSuggester.cs b/src/core/Deal.Infrastructure/Integrations/LocalColumnSuggester.cs index e9c8b5d..84cae4f 100644 --- a/src/core/Deal.Infrastructure/Integrations/LocalColumnSuggester.cs +++ b/src/core/Deal.Infrastructure/Integrations/LocalColumnSuggester.cs @@ -1,247 +1,252 @@ -using System.Globalization; -using System.Text.Json; -using Deal.Contracts.Integrations; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application; -using Deal.Modules.Settings.Application.Models; -// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён (см. CardsService) — -// внутри Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство. -using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules; - -namespace Deal.Infrastructure.Integrations; - -/// -/// Адаптер ИИ-предложений колонок/ключей — детерминированная эвристика этапа 3 (Ruling 3, план Task 14). -/// -/// -/// Реализует порт поверх порта и чистого ядра -/// (модуль Kanban): читает «Неразобранное» (ListInboxWithSourceAsync), -/// считает группы слов-тем и создаёт доски suggested=true (RulesJson {mode:"any", keywords:[…]}, -/// note-обоснование, цвет/позицию даёт ContainersService) и раскладывает карточки (is_new=TRUE, -/// prev_col='inbox', matchHits по правилам доски — Ruling 2). Причины отказов — детерминированные -/// строки прототипа/Ruling 3: «мало карточек в «Неразобранном» (нужно от 6)», «похожие колонки уже -/// есть или нечего сгруппировать»; кулдаун повторов — KV-ключ -/// (прототип COOLDOWN_S L51 + «недавно предлагали — подождите» L95). Журнал CardMoves/ML-сигналы при -/// раскладке НЕ пишутся (suggest.py _assign_ids L220–239 — это не действие пользователя, а предложение). -/// Suggest-keywords читает карточки вне trash/archive (suggest_domain_keywords L172–178). -/// -/// Порт хранилища (карточки «Неразобранного», переносы в колонки-доски). -/// KV-хранилище настроек тенанта (кулдаун lastSuggestAt, как KEY suggest.py L52). -/// Сервис контейнеров: список существующих и создание suggested-колонок с дефолтами. -public sealed class LocalColumnSuggester( -ICardStore store, -ISettingsStore settings, -ContainersService containersService) : IColumnSuggester -{ - // ── Кулдаун повторов (suggest.py COOLDOWN_S L51; KEY lastSuggestAt L52) ── - - // Как часто можно переспрашивать ИИ-предложения: 20 минут (COOLDOWN_S = 20 * 60, L51). - private const long CooldownSeconds = 20 * 60; - - // ── Детерминированные причины (Ruling 3; строки прототипа suggest.py) ── - - // Кулдаун: повторный вызов слишком рано (suggest.py L95 «недавно предлагали — подождите»). - private const string CooldownReason = "недавно предлагали — подождите"; - - // Мало карточек в «Неразобранном»: {0} — порог MIN_INBOX (suggest.py L102). - private const string TooFewCardsReasonFormat = "мало карточек в «Неразобранном» (нужно от {0})"; - - // Групп не вышло: темы похожи на существующие доски или карточкам нечего разделить (L159). - private const string NothingGroupedReason = "похожие колонки уже есть или нечего сгруппировать"; - - // Мало карточек для ключей: нужно хотя бы 3 (suggest_domain_keywords L178). - private const string KeywordsTooFewReason = "мало карточек — сначала накопите заявки (нужно хотя бы 3)"; - - // Повторяющихся слов-маркеров не нашлось (suggest_domain_keywords L187, текст прототипа). - private const string KeywordsEmptyReason = "ИИ не смог выделить ключи — попробуйте ещё раз"; - - // Режим правил колонки-предложения: «любое из условий» (suggest.py _rules_for L68 mode: any). - private const string RulesModeAny = "any"; - - /// - public async Task SuggestColumnsAsync(CancellationToken ct) - { - if (await WithinCooldownAsync(ct)) - { - return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: CooldownReason, Cooldown: true); - } - - IReadOnlyList inbox = await store.ListInboxWithSourceAsync(ct); - if (inbox.Count < SuggestHeuristics.MinInbox) - { - return new SuggestColumnsResultDto( - Ok: false, - Created: 0, - Reason: string.Format(TooFewCardsReasonFormat, SuggestHeuristics.MinInbox), - Cooldown: false); - } - - // Существующие (suggested=false) колонки: похожие темы не предлагаем (suggest.py L105, L138–139). - IReadOnlyList containers = await containersService.ListAsync(ContainerSpaces.Dashboard, ct); - IReadOnlyList existingNames = containers - .Where(container => !container.Suggested) - .Select(container => container.Name) - .ToList(); - - IReadOnlyList plans = SuggestHeuristics.PlanColumns(inbox, existingNames); - if (plans.Count == 0) - { - return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false); - } - - int created = await StoreSuggestedColumnsAsync(inbox, plans, ct); - if (created == 0) - { - // Все колонки откатаны: карточки групп разобраны между чтением и раскладкой (suggest.py L153–156). - return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false); - } - - await WriteLastSuggestAtAsync(ct); - return new SuggestColumnsResultDto(Ok: true, Created: created, Reason: null, Cooldown: false); - } - - /// - public async Task SuggestKeywordsAsync(CancellationToken ct) - { - // Выборка ключей — как suggest_domain_keywords L172–176: карточки вне trash/archive с текстом, - // свежие 40 (ListCardsAsync(null) = «все, кроме taken», ORDER BY received_at DESC). - IReadOnlyList cards = await store.ListCardsAsync(new CardsQuery(null), ct); - List texts = cards - .Where(card => card.Col != CardIds.Trash - && card.Col != CardIds.Archive - && card.SourceMsg.Trim().Length > 0) - .Take(SuggestHeuristics.KeywordsSampleLimit) - .Select(card => card.SourceMsg.Trim()) - .ToList(); - if (texts.Count < SuggestHeuristics.MinKeywordsSample) - { - return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsTooFewReason); - } - - IReadOnlyList keywords = SuggestHeuristics.SuggestDomainKeywords(texts); - if (keywords.Count == 0) - { - return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsEmptyReason); - } - - return new SuggestKeywordsResultDto(Ok: true, Keywords: keywords, Reason: null); - } - - // Создаёт доски-предложения по планам и раскладывает карточки (suggest.py L129–156). - // inbox: Снимок «Неразобранного» (карточки планов берутся из него). - // plans: Планы колонок (SuggestHeuristics.PlanColumns, ≤4). - // ct: Токен отмены. - // Возвращает: Сколько досок реально создано (0 — все откатаны из-за разобранных карточек). - // Каждая доска — suggested=true c правилами {mode:"any", keywords:[тема]} и note-обоснованием. - // Перед раскладкой перечитывается «Неразобранное»: карточки, ушедшие из inbox между снимком и - // раскладкой (пользователь/тик), пропускаются — 1:1 со страховкой _assign_ids L231–233. Если в - // колонку не легло ни одной карточки, пустая доска-предложение откатывается (_rollback_suggested - // L242–248). matchHits считаются по правилам созданной доски (Ruling 2); журнал/ML не пишутся. - private async Task StoreSuggestedColumnsAsync( - IReadOnlyList inbox, - IReadOnlyList plans, - CancellationToken ct) - { - // Свежий снимок inbox — страховка «карточку уже разобрали» (suggest.py _assign_ids L231–233). - HashSet inboxIds = (await store.ListInboxWithSourceAsync(ct)) - .Select(card => card.Id) - .ToHashSet(StringComparer.Ordinal); - Dictionary textByCardId = inbox - .ToDictionary(card => card.Id, card => card.SourceMsg, StringComparer.Ordinal); - - int created = 0; - foreach (SuggestedColumnPlan plan in plans) - { - var rules = new ContainerRulesDto( - Mode: RulesModeAny, - Direction: Array.Empty(), - Keywords: [plan.Word], - Stack: Array.Empty(), - Grade: Array.Empty(), - Exclude: Array.Empty(), - Budget: null); - ContainerDto container = await containersService.CreateAsync(new ContainerCreateDto( - Name: plan.Name, - Description: string.Empty, - Color: null, - Space: ContainerSpaces.Dashboard, - Kind: ContainerKinds.Board, - Suggested: true, - Rules: rules, - Note: plan.Note), ct); - - int placed = 0; - foreach (string cardId in plan.CardIds) - { - if (!inboxIds.Contains(cardId)) - { - continue; // карточка уже разобрана другим предложением/пользователем (L231–233) - } - - IReadOnlyList hits = KanbanColumnRules.ComputeHits(rules, textByCardId[cardId]); - await store.UpdateColumnAsync(new CardColumnUpdateDto( - CardId: cardId, - Col: container.Id, - IsNew: true, - PrevCol: CardIds.Inbox, - ArchivedAt: null, - MatchHits: hits), ct); - placed++; - } - - if (placed == 0) - { - // Ничего не легло — пустое предложение не нужно (suggest.py L152–156). - await containersService.DeleteAsync(container.Id, ct); - continue; - } - - created++; - } - - return created; - } - - // Сработал ли кулдаун: с последнего успешного предложения прошло меньше 20 минут. - // Повреждённое/отсутствующее значение lastSuggestAt — кулдауна нет (как прототип: значение - // пишется только после успеха, L160–161; битый KV — дефолт «никогда»). - // ct: Токен отмены. - // Возвращает: True — повторный вызов слишком рано (ответ {ok:false, reason, cooldown:true}). - private async Task WithinCooldownAsync(CancellationToken ct) - { - SettingValue? row = await settings.GetAsync(SettingsKeys.LastSuggestAt, ct); - if (row is null) - { - return false; - } - - try - { - using JsonDocument document = JsonDocument.Parse(row.ValueJson); - if (document.RootElement.ValueKind != JsonValueKind.Number) - { - return false; - } - - long lastSuggestAt = document.RootElement.GetInt64(); - return DateTimeOffset.UtcNow.ToUnixTimeSeconds() - lastSuggestAt < CooldownSeconds; - } - catch (JsonException) - { - return false; // повреждённое значение не должно блокировать предложения - } - } - - // Записывает метку успешного предложения (suggest.py L160: set_setting(KEY, time.time())). - // ct: Токен отмены. - private Task WriteLastSuggestAtAsync(CancellationToken ct) => - settings.SetAsync( - SettingsKeys.LastSuggestAt, - DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture), - ct); -} +using System.Globalization; +using System.Text.Json; +using Deal.Contracts.Integrations; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Cards.Application.Abstractions; +using Deal.Modules.Cards.Application.Dtos; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +// Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён (см. CardsService) — +// внутри Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство. +using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules; + +namespace Deal.Infrastructure.Integrations; + +/// +/// Адаптер ИИ-предложений колонок/ключей — детерминированная эвристика этапа 3 (Ruling 3, план Task 14). +/// +/// +/// Реализует порт поверх порта и чистого ядра +/// (модуль Kanban): читает «Неразобранное» (ListInboxWithSourceAsync), +/// считает группы слов-тем и создаёт доски suggested=true (RulesJson {mode:"any", keywords:[…]}, +/// note-обоснование, цвет/позицию даёт ContainersService) и раскладывает карточки (is_new=TRUE, +/// prev_col='inbox', matchHits по правилам доски — Ruling 2). Причины отказов — детерминированные +/// строки прототипа/Ruling 3: «мало карточек в «Неразобранном» (нужно от 6)», «похожие колонки уже +/// есть или нечего сгруппировать»; кулдаун повторов — KV-ключ +/// (прототип COOLDOWN_S L51 + «недавно предлагали — подождите» L95). Журнал CardMoves/ML-сигналы при +/// раскладке НЕ пишутся (suggest.py _assign_ids L220–239 — это не действие пользователя, а предложение). +/// Suggest-keywords читает карточки вне trash/archive (suggest_domain_keywords L172–178). +/// +/// Порт хранилища (карточки «Неразобранного», переносы в колонки-доски). +/// KV-хранилище настроек тенанта (кулдаун lastSuggestAt, как KEY suggest.py L52). +/// Сервис контейнеров: список существующих и создание suggested-колонок с дефолтами. +public sealed class LocalColumnSuggester( +ICardStore store, +ISettingsStore settings, +ContainersService containersService) : IColumnSuggester +{ + // ── Кулдаун повторов (suggest.py COOLDOWN_S L51; KEY lastSuggestAt L52) ── + + // Как часто можно переспрашивать ИИ-предложения: 20 минут (COOLDOWN_S = 20 * 60, L51). + private const long CooldownSeconds = 20 * 60; + + // ── Детерминированные причины (Ruling 3; строки прототипа suggest.py) ── + + // Кулдаун: повторный вызов слишком рано (suggest.py L95 «недавно предлагали — подождите»). + private const string CooldownReason = "недавно предлагали — подождите"; + + // Мало карточек в «Неразобранном»: {0} — порог MIN_INBOX (suggest.py L102). + private const string TooFewCardsReasonFormat = "мало карточек в «Неразобранном» (нужно от {0})"; + + // Групп не вышло: темы похожи на существующие доски или карточкам нечего разделить (L159). + private const string NothingGroupedReason = "похожие колонки уже есть или нечего сгруппировать"; + + // Мало карточек для ключей: нужно хотя бы 3 (suggest_domain_keywords L178). + private const string KeywordsTooFewReason = "мало карточек — сначала накопите заявки (нужно хотя бы 3)"; + + // Повторяющихся слов-маркеров не нашлось (suggest_domain_keywords L187, текст прототипа). + private const string KeywordsEmptyReason = "ИИ не смог выделить ключи — попробуйте ещё раз"; + + // Режим правил колонки-предложения: «любое из условий» (suggest.py _rules_for L68 mode: any). + private const string RulesModeAny = "any"; + + /// + public async Task SuggestColumnsAsync(CancellationToken ct) + { + if (await WithinCooldownAsync(ct)) + { + return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: CooldownReason, Cooldown: true); + } + + IReadOnlyList inbox = await store.ListInboxWithSourceAsync(ct); + if (inbox.Count < SuggestHeuristics.MinInbox) + { + return new SuggestColumnsResultDto( + Ok: false, + Created: 0, + Reason: string.Format(TooFewCardsReasonFormat, SuggestHeuristics.MinInbox), + Cooldown: false); + } + + // Существующие (suggested=false) колонки: похожие темы не предлагаем (suggest.py L105, L138–139). + IReadOnlyList containers = await containersService.ListAsync(ContainerSpaces.Dashboard, ct); + IReadOnlyList existingNames = containers + .Where(container => !container.Suggested) + .Select(container => container.Name) + .ToList(); + + IReadOnlyList plans = SuggestHeuristics.PlanColumns(inbox, existingNames); + if (plans.Count == 0) + { + return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false); + } + + int created = await StoreSuggestedColumnsAsync(inbox, plans, ct); + if (created == 0) + { + // Все колонки откатаны: карточки групп разобраны между чтением и раскладкой (suggest.py L153–156). + return new SuggestColumnsResultDto(Ok: false, Created: 0, Reason: NothingGroupedReason, Cooldown: false); + } + + await WriteLastSuggestAtAsync(ct); + return new SuggestColumnsResultDto(Ok: true, Created: created, Reason: null, Cooldown: false); + } + + /// + public async Task SuggestKeywordsAsync(CancellationToken ct) + { + // Выборка ключей — как suggest_domain_keywords L172–176: карточки вне trash/archive с текстом, + // свежие 40 (ListCardsAsync(null) = «все, кроме taken», ORDER BY received_at DESC). + IReadOnlyList cards = await store.ListCardsAsync(new CardsQuery(null), ct); + List texts = cards + .Where(card => card.Col != CardIds.Trash + && card.Col != CardIds.Archive + && card.SourceMsg.Trim().Length > 0) + .Take(SuggestHeuristics.KeywordsSampleLimit) + .Select(card => card.SourceMsg.Trim()) + .ToList(); + if (texts.Count < SuggestHeuristics.MinKeywordsSample) + { + return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsTooFewReason); + } + + IReadOnlyList keywords = SuggestHeuristics.SuggestDomainKeywords(texts); + if (keywords.Count == 0) + { + return new SuggestKeywordsResultDto(Ok: false, Keywords: null, Reason: KeywordsEmptyReason); + } + + return new SuggestKeywordsResultDto(Ok: true, Keywords: keywords, Reason: null); + } + + // Создаёт доски-предложения по планам и раскладывает карточки (suggest.py L129–156). + // inbox: Снимок «Неразобранного» (карточки планов берутся из него). + // plans: Планы колонок (SuggestHeuristics.PlanColumns, ≤4). + // ct: Токен отмены. + // Возвращает: Сколько досок реально создано (0 — все откатаны из-за разобранных карточек). + // Каждая доска — suggested=true c правилами {mode:"any", keywords:[тема]} и note-обоснованием. + // Перед раскладкой перечитывается «Неразобранное»: карточки, ушедшие из inbox между снимком и + // раскладкой (пользователь/тик), пропускаются — 1:1 со страховкой _assign_ids L231–233. Если в + // колонку не легло ни одной карточки, пустая доска-предложение откатывается (_rollback_suggested + // L242–248). matchHits считаются по правилам созданной доски (Ruling 2); журнал/ML не пишутся. + private async Task StoreSuggestedColumnsAsync( + IReadOnlyList inbox, + IReadOnlyList plans, + CancellationToken ct) + { + // Свежий снимок inbox — страховка «карточку уже разобрали» (suggest.py _assign_ids L231–233). + HashSet inboxIds = (await store.ListInboxWithSourceAsync(ct)) + .Select(card => card.Id) + .ToHashSet(StringComparer.Ordinal); + Dictionary textByCardId = inbox + .ToDictionary(card => card.Id, card => card.SourceMsg, StringComparer.Ordinal); + + int created = 0; + foreach (SuggestedColumnPlan plan in plans) + { + var rules = new ContainerRulesDto( + Mode: RulesModeAny, + Direction: Array.Empty(), + Keywords: [plan.Word], + Stack: Array.Empty(), + Grade: Array.Empty(), + Exclude: Array.Empty(), + Budget: null); + ContainerDto container = await containersService.CreateAsync(new ContainerCreateDto( + Name: plan.Name, + Description: string.Empty, + Color: null, + Space: ContainerSpaces.Dashboard, + Kind: ContainerKinds.Board, + Suggested: true, + Rules: rules, + Note: plan.Note), ct); + + int placed = 0; + foreach (string cardId in plan.CardIds) + { + if (!inboxIds.Contains(cardId)) + { + continue; // карточка уже разобрана другим предложением/пользователем (L231–233) + } + + IReadOnlyList hits = KanbanColumnRules.ComputeHits(rules, textByCardId[cardId]); + await store.UpdateColumnAsync(new CardColumnUpdateDto( + CardId: cardId, + Col: container.Id, + IsNew: true, + PrevCol: CardIds.Inbox, + ArchivedAt: null, + MatchHits: hits), ct); + placed++; + } + + if (placed == 0) + { + // Ничего не легло — пустое предложение не нужно (suggest.py L152–156). + await containersService.DeleteAsync(container.Id, ct); + continue; + } + + created++; + } + + return created; + } + + // Сработал ли кулдаун: с последнего успешного предложения прошло меньше 20 минут. + // Повреждённое/отсутствующее значение lastSuggestAt — кулдауна нет (как прототип: значение + // пишется только после успеха, L160–161; битый KV — дефолт «никогда»). + // ct: Токен отмены. + // Возвращает: True — повторный вызов слишком рано (ответ {ok:false, reason, cooldown:true}). + private async Task WithinCooldownAsync(CancellationToken ct) + { + SettingValue? row = await settings.GetAsync(SettingsKeys.LastSuggestAt, ct); + if (row is null) + { + return false; + } + + try + { + using JsonDocument document = JsonDocument.Parse(row.ValueJson); + if (document.RootElement.ValueKind != JsonValueKind.Number) + { + return false; + } + + long lastSuggestAt = document.RootElement.GetInt64(); + return DateTimeOffset.UtcNow.ToUnixTimeSeconds() - lastSuggestAt < CooldownSeconds; + } + catch (JsonException) + { + return false; // повреждённое значение не должно блокировать предложения + } + } + + // Записывает метку успешного предложения (suggest.py L160: set_setting(KEY, time.time())). + // ct: Токен отмены. + private Task WriteLastSuggestAtAsync(CancellationToken ct) => + settings.SetAsync( + SettingsKeys.LastSuggestAt, + DateTimeOffset.UtcNow.ToUnixTimeSeconds().ToString(CultureInfo.InvariantCulture), + ct); +} diff --git a/src/core/Deal.Infrastructure/Integrations/LocalMlClient.cs b/src/core/Deal.Infrastructure/Integrations/LocalMlClient.cs index 004b918..85ff5e1 100644 --- a/src/core/Deal.Infrastructure/Integrations/LocalMlClient.cs +++ b/src/core/Deal.Infrastructure/Integrations/LocalMlClient.cs @@ -1,9 +1,15 @@ using System.Text.Json; using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Settings.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Infrastructure.Integrations; diff --git a/src/core/Deal.Infrastructure/Integrations/MlOutboxQueue.cs b/src/core/Deal.Infrastructure/Integrations/MlOutboxQueue.cs index 9fd439a..97f86a3 100644 --- a/src/core/Deal.Infrastructure/Integrations/MlOutboxQueue.cs +++ b/src/core/Deal.Infrastructure/Integrations/MlOutboxQueue.cs @@ -1,5 +1,9 @@ using System.Security.Cryptography; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; namespace Deal.Infrastructure.Integrations; diff --git a/src/core/Deal.Infrastructure/Integrations/TokenUsageRecorder.cs b/src/core/Deal.Infrastructure/Integrations/TokenUsageRecorder.cs index 0cb46af..9a0ee63 100644 --- a/src/core/Deal.Infrastructure/Integrations/TokenUsageRecorder.cs +++ b/src/core/Deal.Infrastructure/Integrations/TokenUsageRecorder.cs @@ -1,10 +1,15 @@ using System.Text.Json; using System.Text.Json.Nodes; using Deal.Grpc.Ai; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Observability; using Deal.SharedKernel.Tenants; diff --git a/src/core/Deal.Infrastructure/Persistence/Entities/GlobalSettingEntity.cs b/src/core/Deal.Infrastructure/Persistence/Entities/GlobalSettingEntity.cs index 0a2b198..2ae60f0 100644 --- a/src/core/Deal.Infrastructure/Persistence/Entities/GlobalSettingEntity.cs +++ b/src/core/Deal.Infrastructure/Persistence/Entities/GlobalSettingEntity.cs @@ -6,7 +6,7 @@ namespace Deal.Infrastructure.Persistence.Entities; /// /// Единое KV-хранилище всего SaaS-контура (ТЗ §4.1/§8.1): значения задаёт оператор, видят все /// тенанты. Секреты хранятся зашифрованными (префикс enc:), формат значения определяет ключ -/// (). +/// (). /// public sealed class GlobalSettingEntity { diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/AuditLogStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/AuditLogStore.cs index 33ccf00..3b6a0fd 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/AuditLogStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/AuditLogStore.cs @@ -1,6 +1,9 @@ using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/AuthStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/AuthStore.cs index 43c4640..eebb784 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/AuthStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/AuthStore.cs @@ -1,6 +1,9 @@ using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Blacklist.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Blacklist.cs index 7d55e80..9f12e85 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Blacklist.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Blacklist.cs @@ -1,65 +1,69 @@ -using System.Text.Json; -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Discovery.Application; -using Deal.Modules.Discovery.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// Чёрный список Discovery — partial-часть (C32: выделено из общего -/// файла, поведение не менялось): upsert/удаление/чтение/список DiscBlacklist (ON CONFLICT DO UPDATE). -/// -public sealed partial class DiscoveryStore -{ - /// - public async Task UpsertBlacklistAsync(string dialogId, string name, string reason, CancellationToken ct) - { - DiscBlacklistEntity? row = await _dbContext.DiscBlacklist - .FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct); - if (row is null) - { - _dbContext.DiscBlacklist.Add(new DiscBlacklistEntity - { - DialogId = dialogId, - Name = name, - Reason = reason, - CreatedAt = DateTimeOffset.UtcNow, - }); - } - else - { - // add_blacklist L572–575 (ON CONFLICT DO UPDATE): name/reason обновляются, CreatedAt сохраняется. - row.Name = name; - row.Reason = reason; - } - - await _dbContext.SaveChangesAsync(ct); - } - - /// - public async Task RemoveBlacklistAsync(string dialogId, CancellationToken ct) - { - await _dbContext.DiscBlacklist.Where(entry => entry.DialogId == dialogId).ExecuteDeleteAsync(ct); - } - - /// - public async Task GetBlacklistAsync(string dialogId, CancellationToken ct) - { - DiscBlacklistEntity? row = await _dbContext.DiscBlacklist - .AsNoTracking() - .FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct); - return row is null ? null : ToBlacklistDto(row); - } - - /// - public async Task> ListBlacklistAsync(CancellationToken ct) - { - List rows = await _dbContext.DiscBlacklist - .AsNoTracking() - .OrderByDescending(entry => entry.CreatedAt) - .ToListAsync(ct); - return rows.Select(ToBlacklistDto).ToList(); - } - -} +using System.Text.Json; +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// Чёрный список Discovery — partial-часть (C32: выделено из общего +/// файла, поведение не менялось): upsert/удаление/чтение/список DiscBlacklist (ON CONFLICT DO UPDATE). +/// +public sealed partial class DiscoveryStore +{ + /// + public async Task UpsertBlacklistAsync(string dialogId, string name, string reason, CancellationToken ct) + { + DiscBlacklistEntity? row = await _dbContext.DiscBlacklist + .FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct); + if (row is null) + { + _dbContext.DiscBlacklist.Add(new DiscBlacklistEntity + { + DialogId = dialogId, + Name = name, + Reason = reason, + CreatedAt = DateTimeOffset.UtcNow, + }); + } + else + { + // add_blacklist L572–575 (ON CONFLICT DO UPDATE): name/reason обновляются, CreatedAt сохраняется. + row.Name = name; + row.Reason = reason; + } + + await _dbContext.SaveChangesAsync(ct); + } + + /// + public async Task RemoveBlacklistAsync(string dialogId, CancellationToken ct) + { + await _dbContext.DiscBlacklist.Where(entry => entry.DialogId == dialogId).ExecuteDeleteAsync(ct); + } + + /// + public async Task GetBlacklistAsync(string dialogId, CancellationToken ct) + { + DiscBlacklistEntity? row = await _dbContext.DiscBlacklist + .AsNoTracking() + .FirstOrDefaultAsync(entry => entry.DialogId == dialogId, ct); + return row is null ? null : ToBlacklistDto(row); + } + + /// + public async Task> ListBlacklistAsync(CancellationToken ct) + { + List rows = await _dbContext.DiscBlacklist + .AsNoTracking() + .OrderByDescending(entry => entry.CreatedAt) + .ToListAsync(ct); + return rows.Select(ToBlacklistDto).ToList(); + } + +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Candidates.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Candidates.cs index 91255df..454d4c5 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Candidates.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Candidates.cs @@ -1,158 +1,162 @@ -using System.Text.Json; -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Discovery.Application; -using Deal.Modules.Discovery.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// Кандидаты Discovery — partial-часть (C32: выделено из общего файла, -/// поведение не менялось): список/чтение кандидатов (DiscCandidates), мониторинг диалога и чёрный список, -/// создание/патч/статусы joined/rejected и счётчик сбоев вступлений. -/// -public sealed partial class DiscoveryStore -{ - /// - public async Task> ListCandidatesAsync(string taskId, string? status, CancellationToken ct) - { - IQueryable query = _dbContext.DiscCandidates.AsNoTracking().Where(candidate => candidate.TaskId == taskId); - if (status is not null) - { - query = query.Where(candidate => candidate.Status == status); - } - - List rows = await query.OrderBy(candidate => candidate.CreatedAt).ToListAsync(ct); - return rows.Select(ToCandidateDto).ToList(); - } - - /// - public async Task GetCandidateAsync(string dialogId, CancellationToken ct) - { - DiscCandidateEntity? row = await _dbContext.DiscCandidates - .AsNoTracking() - .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); - return row is null ? null : ToCandidateDto(row); - } - - /// - public async Task IsDialogMonitoredAsync(string dialogId, CancellationToken ct) - { - return await _dbContext.Dialogs.AnyAsync(dialog => dialog.Id == dialogId, ct); - } - - /// - public Task IsBlacklistedAsync(string dialogId, CancellationToken ct) - { - return _dbContext.DiscBlacklist.AnyAsync(row => row.DialogId == dialogId, ct); - } - - /// - public async Task CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct) - { - DateTimeOffset now = DateTimeOffset.UtcNow; - _dbContext.DiscCandidates.Add(new DiscCandidateEntity - { - DialogId = row.DialogId, - TaskId = row.TaskId, - Name = row.Name, - Username = row.Username, - Kind = row.Kind, - Hue = row.Hue, - Status = "new", - CreatedAt = now, - UpdatedAt = now, - }); - await _dbContext.SaveChangesAsync(ct); - } - - /// - public async Task DeleteCandidateAsync(string dialogId, CancellationToken ct) - { - await _dbContext.DiscCandidates.Where(candidate => candidate.DialogId == dialogId).ExecuteDeleteAsync(ct); - } - - /// - public async Task PatchCandidateAsync(string dialogId, DiscoveryCandidatePatch patch, CancellationToken ct) - { - DiscCandidateEntity? row = await _dbContext.DiscCandidates - .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); - if (row is null) - { - return false; - } - - ApplyCandidatePatch(row, patch); - row.UpdatedAt = DateTimeOffset.UtcNow; - await _dbContext.SaveChangesAsync(ct); - return true; - } - - /// - public async Task SetCandidateStatusAsync(string dialogId, string status, CancellationToken ct) - { - DiscCandidateEntity? row = await _dbContext.DiscCandidates - .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); - if (row is null) - { - return false; - } - - row.Status = status; - row.UpdatedAt = DateTimeOffset.UtcNow; - await _dbContext.SaveChangesAsync(ct); - return true; - } - - /// - public async Task SetCandidateJoinedAsync(string dialogId, bool autoJoined, CancellationToken ct) - { - DiscCandidateEntity? row = await _dbContext.DiscCandidates - .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); - if (row is null) - { - return false; - } - - // mark_joined L531–534: status=joined + auto_joined. - row.Status = "joined"; - row.AutoJoined = autoJoined; - row.UpdatedAt = DateTimeOffset.UtcNow; - await _dbContext.SaveChangesAsync(ct); - return true; - } - - /// - public async Task IncrementJoinFailuresAsync(string dialogId, CancellationToken ct) - { - DiscCandidateEntity? row = await _dbContext.DiscCandidates - .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); - if (row is null || row.Status != "review") - { - // воркер L404–416: счётчик и удаление трогаем только у живой записи в статусе review. - return null; - } - - row.JoinFailures += 1; - row.UpdatedAt = DateTimeOffset.UtcNow; - await _dbContext.SaveChangesAsync(ct); - return row.JoinFailures; - } - - /// - public async Task SetCandidateRejectedAsync(string dialogId, CancellationToken ct) - { - DiscCandidateEntity? row = await _dbContext.DiscCandidates - .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); - if (row is null) - { - return false; - } - - row.Status = "rejected"; - row.UpdatedAt = DateTimeOffset.UtcNow; - await _dbContext.SaveChangesAsync(ct); - return true; - } - -} +using System.Text.Json; +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// Кандидаты Discovery — partial-часть (C32: выделено из общего файла, +/// поведение не менялось): список/чтение кандидатов (DiscCandidates), мониторинг диалога и чёрный список, +/// создание/патч/статусы joined/rejected и счётчик сбоев вступлений. +/// +public sealed partial class DiscoveryStore +{ + /// + public async Task> ListCandidatesAsync(string taskId, string? status, CancellationToken ct) + { + IQueryable query = _dbContext.DiscCandidates.AsNoTracking().Where(candidate => candidate.TaskId == taskId); + if (status is not null) + { + query = query.Where(candidate => candidate.Status == status); + } + + List rows = await query.OrderBy(candidate => candidate.CreatedAt).ToListAsync(ct); + return rows.Select(ToCandidateDto).ToList(); + } + + /// + public async Task GetCandidateAsync(string dialogId, CancellationToken ct) + { + DiscCandidateEntity? row = await _dbContext.DiscCandidates + .AsNoTracking() + .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); + return row is null ? null : ToCandidateDto(row); + } + + /// + public async Task IsDialogMonitoredAsync(string dialogId, CancellationToken ct) + { + return await _dbContext.Dialogs.AnyAsync(dialog => dialog.Id == dialogId, ct); + } + + /// + public Task IsBlacklistedAsync(string dialogId, CancellationToken ct) + { + return _dbContext.DiscBlacklist.AnyAsync(row => row.DialogId == dialogId, ct); + } + + /// + public async Task CreateCandidateAsync(DiscoveryCandidateRow row, CancellationToken ct) + { + DateTimeOffset now = DateTimeOffset.UtcNow; + _dbContext.DiscCandidates.Add(new DiscCandidateEntity + { + DialogId = row.DialogId, + TaskId = row.TaskId, + Name = row.Name, + Username = row.Username, + Kind = row.Kind, + Hue = row.Hue, + Status = "new", + CreatedAt = now, + UpdatedAt = now, + }); + await _dbContext.SaveChangesAsync(ct); + } + + /// + public async Task DeleteCandidateAsync(string dialogId, CancellationToken ct) + { + await _dbContext.DiscCandidates.Where(candidate => candidate.DialogId == dialogId).ExecuteDeleteAsync(ct); + } + + /// + public async Task PatchCandidateAsync(string dialogId, DiscoveryCandidatePatch patch, CancellationToken ct) + { + DiscCandidateEntity? row = await _dbContext.DiscCandidates + .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); + if (row is null) + { + return false; + } + + ApplyCandidatePatch(row, patch); + row.UpdatedAt = DateTimeOffset.UtcNow; + await _dbContext.SaveChangesAsync(ct); + return true; + } + + /// + public async Task SetCandidateStatusAsync(string dialogId, string status, CancellationToken ct) + { + DiscCandidateEntity? row = await _dbContext.DiscCandidates + .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); + if (row is null) + { + return false; + } + + row.Status = status; + row.UpdatedAt = DateTimeOffset.UtcNow; + await _dbContext.SaveChangesAsync(ct); + return true; + } + + /// + public async Task SetCandidateJoinedAsync(string dialogId, bool autoJoined, CancellationToken ct) + { + DiscCandidateEntity? row = await _dbContext.DiscCandidates + .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); + if (row is null) + { + return false; + } + + // mark_joined L531–534: status=joined + auto_joined. + row.Status = "joined"; + row.AutoJoined = autoJoined; + row.UpdatedAt = DateTimeOffset.UtcNow; + await _dbContext.SaveChangesAsync(ct); + return true; + } + + /// + public async Task IncrementJoinFailuresAsync(string dialogId, CancellationToken ct) + { + DiscCandidateEntity? row = await _dbContext.DiscCandidates + .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); + if (row is null || row.Status != "review") + { + // воркер L404–416: счётчик и удаление трогаем только у живой записи в статусе review. + return null; + } + + row.JoinFailures += 1; + row.UpdatedAt = DateTimeOffset.UtcNow; + await _dbContext.SaveChangesAsync(ct); + return row.JoinFailures; + } + + /// + public async Task SetCandidateRejectedAsync(string dialogId, CancellationToken ct) + { + DiscCandidateEntity? row = await _dbContext.DiscCandidates + .FirstOrDefaultAsync(candidate => candidate.DialogId == dialogId, ct); + if (row is null) + { + return false; + } + + row.Status = "rejected"; + row.UpdatedAt = DateTimeOffset.UtcNow; + await _dbContext.SaveChangesAsync(ct); + return true; + } + +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Logs.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Logs.cs index 2c54db2..543dc31 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Logs.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Logs.cs @@ -1,47 +1,51 @@ -using System.Text.Json; -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Discovery.Application; -using Deal.Modules.Discovery.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// Лог Discovery — partial-часть (C32: выделено из общего файла, -/// поведение не менялось): запись события, счётчик событий с начала суток (квота вступлений) и лог задачи. -/// -public sealed partial class DiscoveryStore -{ - /// - public async Task AddLogAsync(string logId, string taskId, string logEvent, string text, CancellationToken ct) - { - _dbContext.DiscLog.Add(new DiscLogEntity - { - Id = logId, - TaskId = taskId, - Event = logEvent, - Text = text, - CreatedAt = DateTimeOffset.UtcNow, - }); - await _dbContext.SaveChangesAsync(ct); - } - - /// - public async Task CountLogEventAsync(string logEvent, DateTimeOffset sinceUtc, CancellationToken ct) - { - // ban_guard.joins_today_auto L29–35: число событий лога по типу с начала UTC-суток (счётчик квоты). - return await _dbContext.DiscLog.CountAsync(row => row.Event == logEvent && row.CreatedAt >= sinceUtc, ct); - } - - /// - public async Task> ListTaskLogAsync(string taskId, int limit, CancellationToken ct) - { - List rows = await _dbContext.DiscLog - .AsNoTracking() - .Where(log => log.TaskId == taskId) - .OrderByDescending(log => log.CreatedAt) - .Take(limit) - .ToListAsync(ct); - return rows.Select(ToLogDto).ToList(); - } -} +using System.Text.Json; +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// Лог Discovery — partial-часть (C32: выделено из общего файла, +/// поведение не менялось): запись события, счётчик событий с начала суток (квота вступлений) и лог задачи. +/// +public sealed partial class DiscoveryStore +{ + /// + public async Task AddLogAsync(string logId, string taskId, string logEvent, string text, CancellationToken ct) + { + _dbContext.DiscLog.Add(new DiscLogEntity + { + Id = logId, + TaskId = taskId, + Event = logEvent, + Text = text, + CreatedAt = DateTimeOffset.UtcNow, + }); + await _dbContext.SaveChangesAsync(ct); + } + + /// + public async Task CountLogEventAsync(string logEvent, DateTimeOffset sinceUtc, CancellationToken ct) + { + // ban_guard.joins_today_auto L29–35: число событий лога по типу с начала UTC-суток (счётчик квоты). + return await _dbContext.DiscLog.CountAsync(row => row.Event == logEvent && row.CreatedAt >= sinceUtc, ct); + } + + /// + public async Task> ListTaskLogAsync(string taskId, int limit, CancellationToken ct) + { + List rows = await _dbContext.DiscLog + .AsNoTracking() + .Where(log => log.TaskId == taskId) + .OrderByDescending(log => log.CreatedAt) + .Take(limit) + .ToListAsync(ct); + return rows.Select(ToLogDto).ToList(); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Tasks.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Tasks.cs index 5d3402e..4c9b91a 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Tasks.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.Tasks.cs @@ -1,217 +1,221 @@ -using System.Text.Json; -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Discovery.Application; -using Deal.Modules.Discovery.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// Задачи Discovery — partial-часть (C32: выделено из общего файла, -/// поведение не менялось): CRUD задач (DiscTasks), состояние running/paused/done, атомарные инкременты -/// счётчиков и сдвиг поиска, сумма плана (discovery.py db.py; Ruling 9, Task 17). -/// -public sealed partial class DiscoveryStore -{ - /// - public async Task> ListTasksAsync(CancellationToken ct) - { - List rows = await _dbContext.DiscTasks - .AsNoTracking() - .OrderBy(task => task.CreatedAt) - .ToListAsync(ct); - return rows.Select(ToTaskDto).ToList(); - } - - /// - public async Task GetTaskAsync(string taskId, CancellationToken ct) - { - DiscTaskEntity? row = await _dbContext.DiscTasks - .AsNoTracking() - .FirstOrDefaultAsync(task => task.Id == taskId, ct); - return row is null ? null : ToTaskDto(row); - } - - /// - public async Task CreateTaskAsync(DiscoveryTaskRow row, CancellationToken ct) - { - DateTimeOffset now = DateTimeOffset.UtcNow; - _dbContext.DiscTasks.Add(new DiscTaskEntity - { - Id = row.Id, - Name = row.Name, - Description = row.Description, - KeywordsJson = ToJson(row.Keywords), - MinSubscribers = row.MinSubscribers, - Lang = row.Lang, - Threshold = row.Threshold, - SampleSize = row.SampleSize, - PlanJoins = row.PlanJoins, - AutoJoin = row.AutoJoin, - Status = "draft", - CreatedAt = now, - UpdatedAt = now, - }); - await _dbContext.SaveChangesAsync(ct); - } - - /// - public async Task PatchTaskAsync(string taskId, DiscoveryTaskPatch patch, CancellationToken ct) - { - DiscTaskEntity? row = await _dbContext.DiscTasks - .FirstOrDefaultAsync(task => task.Id == taskId, ct); - if (row is null) - { - return false; - } - - ApplyTaskPatch(row, patch); - row.UpdatedAt = DateTimeOffset.UtcNow; - await _dbContext.SaveChangesAsync(ct); - return true; - } - - /// - public async Task DeleteTaskAsync(string taskId, CancellationToken ct) - { - DiscTaskEntity? row = await _dbContext.DiscTasks - .FirstOrDefaultAsync(task => task.Id == taskId, ct); - if (row is null) - { - return false; - } - - // delete_task L314–318: задача удаляется вместе с кандидатами и логом; чёрный список общий — не трогаем. - await _dbContext.DiscCandidates.Where(candidate => candidate.TaskId == taskId).ExecuteDeleteAsync(ct); - await _dbContext.DiscLog.Where(log => log.TaskId == taskId).ExecuteDeleteAsync(ct); - _dbContext.DiscTasks.Remove(row); - await _dbContext.SaveChangesAsync(ct); - return true; - } - - /// - public async Task SetTaskRunningAsync(string taskId, bool resetProgress, CancellationToken ct) - { - DiscTaskEntity? row = await _dbContext.DiscTasks - .FirstOrDefaultAsync(task => task.Id == taskId, ct); - if (row is null) - { - return false; - } - - row.Status = "running"; - if (resetProgress) - { - // start_task L333–339: повторный прогон завершённой/упавшей — свежий проход по ключам. - row.SearchIdx = 0; - row.SearchDone = false; - row.Found = 0; - row.Evaluated = 0; - row.Joined = 0; - row.Rejected = 0; - } - - row.UpdatedAt = DateTimeOffset.UtcNow; - await _dbContext.SaveChangesAsync(ct); - return true; - } - - /// - public async Task SetTaskPausedAsync(string taskId, CancellationToken ct) - { - DiscTaskEntity? row = await _dbContext.DiscTasks - .FirstOrDefaultAsync(task => task.Id == taskId, ct); - if (row is null) - { - return false; - } - - row.Status = "paused"; - row.UpdatedAt = DateTimeOffset.UtcNow; - await _dbContext.SaveChangesAsync(ct); - return true; - } - - /// - public async Task SetTaskDoneAsync(string taskId, CancellationToken ct) - { - DiscTaskEntity? row = await _dbContext.DiscTasks - .FirstOrDefaultAsync(task => task.Id == taskId, ct); - if (row is null) - { - return false; - } - - // воркер _finish_done L126–136: план вступлений выполнен — status=done, бюджет планов освобождается. - row.Status = "done"; - row.UpdatedAt = DateTimeOffset.UtcNow; - await _dbContext.SaveChangesAsync(ct); - return true; - } - - /// - public async Task BumpTaskCounterAsync(string taskId, DiscoveryCounterField field, int n, CancellationToken ct) - { - DiscTaskEntity? row = await _dbContext.DiscTasks - .FirstOrDefaultAsync(task => task.Id == taskId, ct); - if (row is null || n <= 0) - { - return row is not null; - } - - // bump_counter L359–368: приращение счётчика + bump UpdatedAt (python читает и пишет абсолютное значение). - switch (field) - { - case DiscoveryCounterField.Found: - row.Found += n; - break; - case DiscoveryCounterField.Evaluated: - row.Evaluated += n; - break; - case DiscoveryCounterField.Joined: - row.Joined += n; - break; - case DiscoveryCounterField.Rejected: - row.Rejected += n; - break; - default: - throw new ArgumentOutOfRangeException(nameof(field), field, "Неизвестный счётчик задачи"); - } - - row.UpdatedAt = DateTimeOffset.UtcNow; - await _dbContext.SaveChangesAsync(ct); - return true; - } - - /// - public async Task AdvanceSearchAsync(string taskId, int nextIndex, bool searchDone, CancellationToken ct) - { - DiscTaskEntity? row = await _dbContext.DiscTasks - .FirstOrDefaultAsync(task => task.Id == taskId, ct); - if (row is null) - { - return false; - } - - // advance_search L371–380: новый индекс и флаг завершения прохода (значения считает сервис). - row.SearchIdx = nextIndex; - row.SearchDone = searchDone; - row.UpdatedAt = DateTimeOffset.UtcNow; - await _dbContext.SaveChangesAsync(ct); - return true; - } - - /// - public async Task SumActivePlanAsync(string? excludeTaskId, CancellationToken ct) - { - IQueryable query = _dbContext.DiscTasks - .Where(task => task.Status != "done" && task.Status != "failed"); - if (excludeTaskId is not null) - { - query = query.Where(task => task.Id != excludeTaskId); - } - - return await query.SumAsync(task => task.PlanJoins, ct); - } - -} +using System.Text.Json; +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// Задачи Discovery — partial-часть (C32: выделено из общего файла, +/// поведение не менялось): CRUD задач (DiscTasks), состояние running/paused/done, атомарные инкременты +/// счётчиков и сдвиг поиска, сумма плана (discovery.py db.py; Ruling 9, Task 17). +/// +public sealed partial class DiscoveryStore +{ + /// + public async Task> ListTasksAsync(CancellationToken ct) + { + List rows = await _dbContext.DiscTasks + .AsNoTracking() + .OrderBy(task => task.CreatedAt) + .ToListAsync(ct); + return rows.Select(ToTaskDto).ToList(); + } + + /// + public async Task GetTaskAsync(string taskId, CancellationToken ct) + { + DiscTaskEntity? row = await _dbContext.DiscTasks + .AsNoTracking() + .FirstOrDefaultAsync(task => task.Id == taskId, ct); + return row is null ? null : ToTaskDto(row); + } + + /// + public async Task CreateTaskAsync(DiscoveryTaskRow row, CancellationToken ct) + { + DateTimeOffset now = DateTimeOffset.UtcNow; + _dbContext.DiscTasks.Add(new DiscTaskEntity + { + Id = row.Id, + Name = row.Name, + Description = row.Description, + KeywordsJson = ToJson(row.Keywords), + MinSubscribers = row.MinSubscribers, + Lang = row.Lang, + Threshold = row.Threshold, + SampleSize = row.SampleSize, + PlanJoins = row.PlanJoins, + AutoJoin = row.AutoJoin, + Status = "draft", + CreatedAt = now, + UpdatedAt = now, + }); + await _dbContext.SaveChangesAsync(ct); + } + + /// + public async Task PatchTaskAsync(string taskId, DiscoveryTaskPatch patch, CancellationToken ct) + { + DiscTaskEntity? row = await _dbContext.DiscTasks + .FirstOrDefaultAsync(task => task.Id == taskId, ct); + if (row is null) + { + return false; + } + + ApplyTaskPatch(row, patch); + row.UpdatedAt = DateTimeOffset.UtcNow; + await _dbContext.SaveChangesAsync(ct); + return true; + } + + /// + public async Task DeleteTaskAsync(string taskId, CancellationToken ct) + { + DiscTaskEntity? row = await _dbContext.DiscTasks + .FirstOrDefaultAsync(task => task.Id == taskId, ct); + if (row is null) + { + return false; + } + + // delete_task L314–318: задача удаляется вместе с кандидатами и логом; чёрный список общий — не трогаем. + await _dbContext.DiscCandidates.Where(candidate => candidate.TaskId == taskId).ExecuteDeleteAsync(ct); + await _dbContext.DiscLog.Where(log => log.TaskId == taskId).ExecuteDeleteAsync(ct); + _dbContext.DiscTasks.Remove(row); + await _dbContext.SaveChangesAsync(ct); + return true; + } + + /// + public async Task SetTaskRunningAsync(string taskId, bool resetProgress, CancellationToken ct) + { + DiscTaskEntity? row = await _dbContext.DiscTasks + .FirstOrDefaultAsync(task => task.Id == taskId, ct); + if (row is null) + { + return false; + } + + row.Status = "running"; + if (resetProgress) + { + // start_task L333–339: повторный прогон завершённой/упавшей — свежий проход по ключам. + row.SearchIdx = 0; + row.SearchDone = false; + row.Found = 0; + row.Evaluated = 0; + row.Joined = 0; + row.Rejected = 0; + } + + row.UpdatedAt = DateTimeOffset.UtcNow; + await _dbContext.SaveChangesAsync(ct); + return true; + } + + /// + public async Task SetTaskPausedAsync(string taskId, CancellationToken ct) + { + DiscTaskEntity? row = await _dbContext.DiscTasks + .FirstOrDefaultAsync(task => task.Id == taskId, ct); + if (row is null) + { + return false; + } + + row.Status = "paused"; + row.UpdatedAt = DateTimeOffset.UtcNow; + await _dbContext.SaveChangesAsync(ct); + return true; + } + + /// + public async Task SetTaskDoneAsync(string taskId, CancellationToken ct) + { + DiscTaskEntity? row = await _dbContext.DiscTasks + .FirstOrDefaultAsync(task => task.Id == taskId, ct); + if (row is null) + { + return false; + } + + // воркер _finish_done L126–136: план вступлений выполнен — status=done, бюджет планов освобождается. + row.Status = "done"; + row.UpdatedAt = DateTimeOffset.UtcNow; + await _dbContext.SaveChangesAsync(ct); + return true; + } + + /// + public async Task BumpTaskCounterAsync(string taskId, DiscoveryCounterField field, int n, CancellationToken ct) + { + DiscTaskEntity? row = await _dbContext.DiscTasks + .FirstOrDefaultAsync(task => task.Id == taskId, ct); + if (row is null || n <= 0) + { + return row is not null; + } + + // bump_counter L359–368: приращение счётчика + bump UpdatedAt (python читает и пишет абсолютное значение). + switch (field) + { + case DiscoveryCounterField.Found: + row.Found += n; + break; + case DiscoveryCounterField.Evaluated: + row.Evaluated += n; + break; + case DiscoveryCounterField.Joined: + row.Joined += n; + break; + case DiscoveryCounterField.Rejected: + row.Rejected += n; + break; + default: + throw new ArgumentOutOfRangeException(nameof(field), field, "Неизвестный счётчик задачи"); + } + + row.UpdatedAt = DateTimeOffset.UtcNow; + await _dbContext.SaveChangesAsync(ct); + return true; + } + + /// + public async Task AdvanceSearchAsync(string taskId, int nextIndex, bool searchDone, CancellationToken ct) + { + DiscTaskEntity? row = await _dbContext.DiscTasks + .FirstOrDefaultAsync(task => task.Id == taskId, ct); + if (row is null) + { + return false; + } + + // advance_search L371–380: новый индекс и флаг завершения прохода (значения считает сервис). + row.SearchIdx = nextIndex; + row.SearchDone = searchDone; + row.UpdatedAt = DateTimeOffset.UtcNow; + await _dbContext.SaveChangesAsync(ct); + return true; + } + + /// + public async Task SumActivePlanAsync(string? excludeTaskId, CancellationToken ct) + { + IQueryable query = _dbContext.DiscTasks + .Where(task => task.Status != "done" && task.Status != "failed"); + if (excludeTaskId is not null) + { + query = query.Where(task => task.Id != excludeTaskId); + } + + return await query.SumAsync(task => task.PlanJoins, ct); + } + +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.cs index c530307..4490e39 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/DiscoveryStore.cs @@ -1,219 +1,223 @@ -using System.Text.Json; -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Discovery.Application; -using Deal.Modules.Discovery.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер хранилища Discovery: таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog схемы тенанта (Ruling 9, Task 17). -/// -/// -/// Реализация порта на (эталон TelegramStore/ -/// KanbanStore). Семантика 1:1 с python discovery.py/db.py: INSERT со служебными дефолтами (draft/new/счётчики 0), -/// UPDATE присутствующих полей патча с bump UpdatedAt, атомарные инкременты счётчиков (bump_counter), upsert -/// чёрного списка (ON CONFLICT DO UPDATE name/reason при сохранённом CreatedAt), каскад delete_task -/// (задача + кандидаты + лог). JSON-колонки (keywords/marks/topics) — text с сериализованным JSON camelCase; -/// времена — timestamptz (DateTimeOffset), наружу epoch-ms. Чтение Dialogs (проверка «уже мониторится») — тот же -/// TenantDbContext (владелец каталога — модуль Telegram; доступ по БД, реверс-зависимостей нет). -/// C32: класс разделён на partial-файлы по агрегатам (DiscoveryStore.Tasks/Candidates/Blacklist/Logs.cs); -/// маппинг DTO ↔ строк остаётся в этом файле. Поведение и сигнатуры не менялись. -/// -public sealed partial class DiscoveryStore : IDiscoveryStore -{ - private readonly TenantDbContext _dbContext; - - /// - /// Создаёт EF-адаптер хранилища Discovery (зависимости — tenant-контекст БД). - /// - /// Scoped-контекст тенанта запроса (search_path). - public DiscoveryStore(TenantDbContext dbContext) - { - _dbContext = dbContext; - } - - // Опции JSON: camelCase для wire-форм Discovery (1:1 с §4.8; эталон KanbanStore JsonOptions). - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - }; - // Маппит строку задачи в DTO (task_view L116–137; keywords — JSON-список, времена — epoch-ms). - private static DiscoveryTaskDto ToTaskDto(DiscTaskEntity row) - { - return new DiscoveryTaskDto( - row.Id, - row.Name, - row.Description, - FromJson(row.KeywordsJson), - row.MinSubscribers, - row.Lang, - row.Threshold, - row.SampleSize, - row.PlanJoins, - row.AutoJoin, - row.Status, - row.SearchIdx, - row.SearchDone, - row.Found, - row.Evaluated, - row.Joined, - row.Rejected, - row.CreatedAt.ToUnixTimeMilliseconds(), - row.UpdatedAt.ToUnixTimeMilliseconds()); - } - - // Маппит строку кандидата в DTO (candidate_view L140–158; marks/topics — JSON-списки). - private static DiscoveryCandidateDto ToCandidateDto(DiscCandidateEntity row) - { - return new DiscoveryCandidateDto( - row.DialogId, - row.TaskId, - row.Name, - row.Username, - row.Kind, - row.Hue, - row.Participants, - row.LangRu, - FromJson(row.MarksJson), - FromJson(row.TopicsJson), - row.FitRatio, - row.Status, - row.AutoJoined, - row.JoinFailures, - row.CreatedAt.ToUnixTimeMilliseconds(), - row.UpdatedAt.ToUnixTimeMilliseconds()); - } - - // Маппит строку чёрного списка в DTO (blacklist_view L161–167). - private static DiscoveryBlacklistDto ToBlacklistDto(DiscBlacklistEntity row) - { - return new DiscoveryBlacklistDto(row.DialogId, row.Name, row.Reason, row.CreatedAt.ToUnixTimeMilliseconds()); - } - - // Маппит строку лога в DTO (log_view L170–177). - private static DiscoveryLogDto ToLogDto(DiscLogEntity row) - { - return new DiscoveryLogDto(row.Id, row.TaskId, row.Event, row.Text, row.CreatedAt.ToUnixTimeMilliseconds()); - } - - // Применяет патч задачи к строке (patch_task L292–310: только присутствующие поля; keywords — JSON). - private static void ApplyTaskPatch(DiscTaskEntity row, DiscoveryTaskPatch patch) - { - if (patch.Name is not null) - { - row.Name = patch.Name; - } - - if (patch.Description is not null) - { - row.Description = patch.Description; - } - - if (patch.Keywords is not null) - { - row.KeywordsJson = ToJson(patch.Keywords); - } - - if (patch.MinSubscribers is int minSubscribers) - { - row.MinSubscribers = minSubscribers; - } - - if (patch.Lang is not null) - { - row.Lang = patch.Lang; - } - - if (patch.Threshold is int threshold) - { - row.Threshold = threshold; - } - - if (patch.SampleSize is int sampleSize) - { - row.SampleSize = sampleSize; - } - - if (patch.PlanJoins is int planJoins) - { - row.PlanJoins = planJoins; - } - - if (patch.AutoJoin is bool autoJoin) - { - row.AutoJoin = autoJoin; - } - } - - // Применяет патч кандидата к строке (set_candidate L468–492; marks/topics — JSON-замена). - private static void ApplyCandidatePatch(DiscCandidateEntity row, DiscoveryCandidatePatch patch) - { - if (patch.Name is not null) - { - row.Name = patch.Name; - } - - if (patch.Username is not null) - { - row.Username = patch.Username; - } - - if (patch.Kind is not null) - { - row.Kind = patch.Kind; - } - - if (patch.Hue is not null) - { - row.Hue = patch.Hue; - } - - if (patch.Participants is int participants) - { - row.Participants = participants; - } - - if (patch.LangRu is bool langRu) - { - row.LangRu = langRu; - } - - if (patch.Marks is not null) - { - row.MarksJson = ToJson(patch.Marks); - } - - if (patch.Topics is not null) - { - row.TopicsJson = ToJson(patch.Topics); - } - - if (patch.FitRatio is double fitRatio) - { - row.FitRatio = fitRatio; - } - - if (patch.AutoJoined is bool autoJoined) - { - row.AutoJoined = autoJoined; - } - } - - // Разбирает JSON-массив колонки (повреждённая строка/не-массив → пустой список, python _loads L73–77). - private static IReadOnlyList FromJson(string json) - { - try - { - return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; - } - catch (JsonException) - { - return []; - } - } - - // Сериализует значение в JSON (camelCase, конвенция value_json). - private static string ToJson(T value) => JsonSerializer.Serialize(value, JsonOptions); -} +using System.Text.Json; +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// EF-адаптер хранилища Discovery: таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog схемы тенанта (Ruling 9, Task 17). +/// +/// +/// Реализация порта на (эталон TelegramStore/ +/// KanbanStore). Семантика 1:1 с python discovery.py/db.py: INSERT со служебными дефолтами (draft/new/счётчики 0), +/// UPDATE присутствующих полей патча с bump UpdatedAt, атомарные инкременты счётчиков (bump_counter), upsert +/// чёрного списка (ON CONFLICT DO UPDATE name/reason при сохранённом CreatedAt), каскад delete_task +/// (задача + кандидаты + лог). JSON-колонки (keywords/marks/topics) — text с сериализованным JSON camelCase; +/// времена — timestamptz (DateTimeOffset), наружу epoch-ms. Чтение Dialogs (проверка «уже мониторится») — тот же +/// TenantDbContext (владелец каталога — модуль Telegram; доступ по БД, реверс-зависимостей нет). +/// C32: класс разделён на partial-файлы по агрегатам (DiscoveryStore.Tasks/Candidates/Blacklist/Logs.cs); +/// маппинг DTO ↔ строк остаётся в этом файле. Поведение и сигнатуры не менялись. +/// +public sealed partial class DiscoveryStore : IDiscoveryStore +{ + private readonly TenantDbContext _dbContext; + + /// + /// Создаёт EF-адаптер хранилища Discovery (зависимости — tenant-контекст БД). + /// + /// Scoped-контекст тенанта запроса (search_path). + public DiscoveryStore(TenantDbContext dbContext) + { + _dbContext = dbContext; + } + + // Опции JSON: camelCase для wire-форм Discovery (1:1 с §4.8; эталон KanbanStore JsonOptions). + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + // Маппит строку задачи в DTO (task_view L116–137; keywords — JSON-список, времена — epoch-ms). + private static DiscoveryTaskDto ToTaskDto(DiscTaskEntity row) + { + return new DiscoveryTaskDto( + row.Id, + row.Name, + row.Description, + FromJson(row.KeywordsJson), + row.MinSubscribers, + row.Lang, + row.Threshold, + row.SampleSize, + row.PlanJoins, + row.AutoJoin, + row.Status, + row.SearchIdx, + row.SearchDone, + row.Found, + row.Evaluated, + row.Joined, + row.Rejected, + row.CreatedAt.ToUnixTimeMilliseconds(), + row.UpdatedAt.ToUnixTimeMilliseconds()); + } + + // Маппит строку кандидата в DTO (candidate_view L140–158; marks/topics — JSON-списки). + private static DiscoveryCandidateDto ToCandidateDto(DiscCandidateEntity row) + { + return new DiscoveryCandidateDto( + row.DialogId, + row.TaskId, + row.Name, + row.Username, + row.Kind, + row.Hue, + row.Participants, + row.LangRu, + FromJson(row.MarksJson), + FromJson(row.TopicsJson), + row.FitRatio, + row.Status, + row.AutoJoined, + row.JoinFailures, + row.CreatedAt.ToUnixTimeMilliseconds(), + row.UpdatedAt.ToUnixTimeMilliseconds()); + } + + // Маппит строку чёрного списка в DTO (blacklist_view L161–167). + private static DiscoveryBlacklistDto ToBlacklistDto(DiscBlacklistEntity row) + { + return new DiscoveryBlacklistDto(row.DialogId, row.Name, row.Reason, row.CreatedAt.ToUnixTimeMilliseconds()); + } + + // Маппит строку лога в DTO (log_view L170–177). + private static DiscoveryLogDto ToLogDto(DiscLogEntity row) + { + return new DiscoveryLogDto(row.Id, row.TaskId, row.Event, row.Text, row.CreatedAt.ToUnixTimeMilliseconds()); + } + + // Применяет патч задачи к строке (patch_task L292–310: только присутствующие поля; keywords — JSON). + private static void ApplyTaskPatch(DiscTaskEntity row, DiscoveryTaskPatch patch) + { + if (patch.Name is not null) + { + row.Name = patch.Name; + } + + if (patch.Description is not null) + { + row.Description = patch.Description; + } + + if (patch.Keywords is not null) + { + row.KeywordsJson = ToJson(patch.Keywords); + } + + if (patch.MinSubscribers is int minSubscribers) + { + row.MinSubscribers = minSubscribers; + } + + if (patch.Lang is not null) + { + row.Lang = patch.Lang; + } + + if (patch.Threshold is int threshold) + { + row.Threshold = threshold; + } + + if (patch.SampleSize is int sampleSize) + { + row.SampleSize = sampleSize; + } + + if (patch.PlanJoins is int planJoins) + { + row.PlanJoins = planJoins; + } + + if (patch.AutoJoin is bool autoJoin) + { + row.AutoJoin = autoJoin; + } + } + + // Применяет патч кандидата к строке (set_candidate L468–492; marks/topics — JSON-замена). + private static void ApplyCandidatePatch(DiscCandidateEntity row, DiscoveryCandidatePatch patch) + { + if (patch.Name is not null) + { + row.Name = patch.Name; + } + + if (patch.Username is not null) + { + row.Username = patch.Username; + } + + if (patch.Kind is not null) + { + row.Kind = patch.Kind; + } + + if (patch.Hue is not null) + { + row.Hue = patch.Hue; + } + + if (patch.Participants is int participants) + { + row.Participants = participants; + } + + if (patch.LangRu is bool langRu) + { + row.LangRu = langRu; + } + + if (patch.Marks is not null) + { + row.MarksJson = ToJson(patch.Marks); + } + + if (patch.Topics is not null) + { + row.TopicsJson = ToJson(patch.Topics); + } + + if (patch.FitRatio is double fitRatio) + { + row.FitRatio = fitRatio; + } + + if (patch.AutoJoined is bool autoJoined) + { + row.AutoJoined = autoJoined; + } + } + + // Разбирает JSON-массив колонки (повреждённая строка/не-массив → пустой список, python _loads L73–77). + private static IReadOnlyList FromJson(string json) + { + try + { + return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + } + catch (JsonException) + { + return []; + } + } + + // Сериализует значение в JSON (camelCase, конвенция value_json). + private static string ToJson(T value) => JsonSerializer.Serialize(value, JsonOptions); +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/GlobalSettingsStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/GlobalSettingsStore.cs index 6973d94..81e0269 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/GlobalSettingsStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/GlobalSettingsStore.cs @@ -1,51 +1,53 @@ -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Settings.Application; -using Deal.Modules.Settings.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер KV-хранилища глобальных (системных) настроек оператора: таблица public.global_settings. -/// -/// -/// Маппинг 1:1 со строкой таблицы: key/value/updated_at (сущность GlobalSettingEntity, системный -/// DealDbContext схемы public). Порт оперирует готовыми JSON-строками (сериализацию выполняет -/// владелец ключа), поэтому адаптер хранит значение как текст без интерпретации. -/// пишет updated_at = UTC-now. -/// -public sealed class GlobalSettingsStore(DealDbContext dbContext) : IGlobalSettingsStore -{ - /// - public async Task GetAsync(string key, CancellationToken ct) - { - var entity = await dbContext.GlobalSettings - .AsNoTracking() - .SingleOrDefaultAsync(s => s.Key == key, ct); - return entity is null ? null : new SettingValue(entity.Key, entity.Value, entity.UpdatedAt); - } - - /// - public async Task SetAsync(string key, string valueJson, CancellationToken ct) - { - // Upsert по ключу (PK): существующая строка обновляется, отсутствующая — добавляется. - GlobalSettingEntity? entity = await dbContext.GlobalSettings - .SingleOrDefaultAsync(s => s.Key == key, ct); - if (entity is null) - { - dbContext.GlobalSettings.Add(new GlobalSettingEntity - { - Key = key, - Value = valueJson, - UpdatedAt = DateTimeOffset.UtcNow, - }); - } - else - { - entity.Value = valueJson; - entity.UpdatedAt = DateTimeOffset.UtcNow; - } - - await dbContext.SaveChangesAsync(ct); - } -} +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// EF-адаптер KV-хранилища глобальных (системных) настроек оператора: таблица public.global_settings. +/// +/// +/// Маппинг 1:1 со строкой таблицы: key/value/updated_at (сущность GlobalSettingEntity, системный +/// DealDbContext схемы public). Порт оперирует готовыми JSON-строками (сериализацию выполняет +/// владелец ключа), поэтому адаптер хранит значение как текст без интерпретации. +/// пишет updated_at = UTC-now. +/// +public sealed class GlobalSettingsStore(DealDbContext dbContext) : IGlobalSettingsStore +{ + /// + public async Task GetAsync(string key, CancellationToken ct) + { + var entity = await dbContext.GlobalSettings + .AsNoTracking() + .SingleOrDefaultAsync(s => s.Key == key, ct); + return entity is null ? null : new SettingValue(entity.Key, entity.Value, entity.UpdatedAt); + } + + /// + public async Task SetAsync(string key, string valueJson, CancellationToken ct) + { + // Upsert по ключу (PK): существующая строка обновляется, отсутствующая — добавляется. + GlobalSettingEntity? entity = await dbContext.GlobalSettings + .SingleOrDefaultAsync(s => s.Key == key, ct); + if (entity is null) + { + dbContext.GlobalSettings.Add(new GlobalSettingEntity + { + Key = key, + Value = valueJson, + UpdatedAt = DateTimeOffset.UtcNow, + }); + } + else + { + entity.Value = valueJson; + entity.UpdatedAt = DateTimeOffset.UtcNow; + } + + await dbContext.SaveChangesAsync(ct); + } +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/InviteStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/InviteStore.cs index acf3594..c8bc9ca 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/InviteStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/InviteStore.cs @@ -1,6 +1,9 @@ using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Cards.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Cards.cs index 2dad655..6505a87 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Cards.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Cards.cs @@ -1,7 +1,10 @@ using System.Text.Json; using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Comments.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Comments.cs index 5d038e3..82ff32a 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Comments.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Comments.cs @@ -1,90 +1,93 @@ -using System.Text.Json; -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// Комментарии и журнал действий — partial-часть (C32: выделено из общего -/// файла, поведение не менялось): LeadComments (список/добавление), CardMoves (запись/счётчик learning) -/// и few-shot-примеры разметки для ИИ (leads.py add_comment/_log_learning/_learning_examples). -/// -public sealed partial class KanbanStore -{ - /// - public async Task> ListCommentsAsync(string cardId, CancellationToken ct) - { - List entities = await _dbContext.LeadComments - .AsNoTracking() - .Where(comment => comment.CardId == cardId) - .OrderBy(comment => comment.CreatedAt) - .ThenBy(comment => comment.Id) - .ToListAsync(ct); - return entities.Select(ToCommentDto).ToList(); - } - - /// - public async Task AddCommentAsync(string commentId, string cardId, string by, string text, CancellationToken ct) - { - // CreatedAt — UTC-now (leads.py add_comment L259–265). Целостность ссылки на карточку держит FK: - // комментарий без карточки не запишется (404-семантику несуществующей карточки отдаёт сервис, - // прочитав карточку перед добавлением, — адаптеру возвращать нечего: порт void). - _dbContext.LeadComments.Add(new LeadCommentEntity - { - Id = commentId, - CardId = cardId, - By = by, - Text = text, - CreatedAt = DateTimeOffset.UtcNow, - }); - await _dbContext.SaveChangesAsync(ct); - } - - /// - public async Task AddMoveAsync(CardMoveDto move, CancellationToken ct) - { - // Каждое действие пользователя пишет строку журнала (leads.py _log_learning L40–44); счётчик - // learning = число записей CardMoves (Ruling 4). CreatedAt — UTC-now. - _dbContext.CardMoves.Add(new CardMoveEntity - { - Id = move.Id, - LeadId = move.LeadId, - Action = move.Action, - FromCol = move.FromCol, - ToCol = move.ToCol, - CreatedAt = DateTimeOffset.UtcNow, - }); - await _dbContext.SaveChangesAsync(ct); - } - - /// - public Task CountMovesAsync(CancellationToken ct) => _dbContext.CardMoves.CountAsync(ct); - - /// - public async Task> GetAiMarkupExamplesAsync(int limit, CancellationToken ct) - { - // Few-shot-примеры ИИ-классификации (pipeline.py _learning_examples L201–215): join журнала с карточками, - // действия move/restore, цель не служебная (trash/archive), source_msg непустой, свежие первыми. - return await _dbContext.CardMoves - .AsNoTracking() - .Where(move => move.ToCol != null && move.ToCol != CardIds.Trash && move.ToCol != CardIds.Archive) - .Join( - _dbContext.Cards.AsNoTracking(), - move => move.LeadId, - card => card.Id, - (move, card) => new { move, card }) - .Where(joined => (joined.move.Action == MoveAction || joined.move.Action == RestoreAction) - && joined.card.SourceMsg.Length > 0) - .OrderByDescending(joined => joined.move.CreatedAt) - .Take(limit) - .Select(joined => new AiMarkupExampleDto(joined.card.SourceMsg, joined.move.ToCol!)) - .ToListAsync(ct); - } - - /// -} +using System.Text.Json; +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Cards.Application.Abstractions; +using Deal.Modules.Cards.Application.Dtos; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// Комментарии и журнал действий — partial-часть (C32: выделено из общего +/// файла, поведение не менялось): LeadComments (список/добавление), CardMoves (запись/счётчик learning) +/// и few-shot-примеры разметки для ИИ (leads.py add_comment/_log_learning/_learning_examples). +/// +public sealed partial class KanbanStore +{ + /// + public async Task> ListCommentsAsync(string cardId, CancellationToken ct) + { + List entities = await _dbContext.LeadComments + .AsNoTracking() + .Where(comment => comment.CardId == cardId) + .OrderBy(comment => comment.CreatedAt) + .ThenBy(comment => comment.Id) + .ToListAsync(ct); + return entities.Select(ToCommentDto).ToList(); + } + + /// + public async Task AddCommentAsync(string commentId, string cardId, string by, string text, CancellationToken ct) + { + // CreatedAt — UTC-now (leads.py add_comment L259–265). Целостность ссылки на карточку держит FK: + // комментарий без карточки не запишется (404-семантику несуществующей карточки отдаёт сервис, + // прочитав карточку перед добавлением, — адаптеру возвращать нечего: порт void). + _dbContext.LeadComments.Add(new LeadCommentEntity + { + Id = commentId, + CardId = cardId, + By = by, + Text = text, + CreatedAt = DateTimeOffset.UtcNow, + }); + await _dbContext.SaveChangesAsync(ct); + } + + /// + public async Task AddMoveAsync(CardMoveDto move, CancellationToken ct) + { + // Каждое действие пользователя пишет строку журнала (leads.py _log_learning L40–44); счётчик + // learning = число записей CardMoves (Ruling 4). CreatedAt — UTC-now. + _dbContext.CardMoves.Add(new CardMoveEntity + { + Id = move.Id, + LeadId = move.LeadId, + Action = move.Action, + FromCol = move.FromCol, + ToCol = move.ToCol, + CreatedAt = DateTimeOffset.UtcNow, + }); + await _dbContext.SaveChangesAsync(ct); + } + + /// + public Task CountMovesAsync(CancellationToken ct) => _dbContext.CardMoves.CountAsync(ct); + + /// + public async Task> GetAiMarkupExamplesAsync(int limit, CancellationToken ct) + { + // Few-shot-примеры ИИ-классификации (pipeline.py _learning_examples L201–215): join журнала с карточками, + // действия move/restore, цель не служебная (trash/archive), source_msg непустой, свежие первыми. + return await _dbContext.CardMoves + .AsNoTracking() + .Where(move => move.ToCol != null && move.ToCol != CardIds.Trash && move.ToCol != CardIds.Archive) + .Join( + _dbContext.Cards.AsNoTracking(), + move => move.LeadId, + card => card.Id, + (move, card) => new { move, card }) + .Where(joined => (joined.move.Action == MoveAction || joined.move.Action == RestoreAction) + && joined.card.SourceMsg.Length > 0) + .OrderByDescending(joined => joined.move.CreatedAt) + .Take(limit) + .Select(joined => new AiMarkupExampleDto(joined.card.SourceMsg, joined.move.ToCol!)) + .ToListAsync(ct); + } + + /// +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Containers.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Containers.cs index 5d0bedd..5bb288d 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Containers.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.Containers.cs @@ -2,8 +2,11 @@ using Deal.Infrastructure.Persistence.Entities; using Deal.Modules.Cards.Application.Abstractions; using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.StorageRules.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.StorageRules.cs index bd80729..0db55a9 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.StorageRules.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.StorageRules.cs @@ -1,144 +1,147 @@ -using System.Text.Json; -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// Правила хранения, конверсии и вход эвристики suggest — partial-часть -/// (C32: выделено из общего файла, поведение не менялось): кандидаты тика (автоархив/очистка архива и -/// корзины), batch-архивация и жёсткое удаление пачки, пересчёт бюджетных конверсий и «Неразобранное» -/// с исходным текстом для ИИ-предложений (tick_storage/recompute_conversions/suggest, Rulings 3/7/8). -/// -public sealed partial class KanbanStore -{ - /// - public async Task> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct) - { - // Кандидаты автоархива: карточки пользовательских колонок (kind=board) и «Неразобранного» - // (tick_storage L462–467) — колонки динамические, поэтому id колонок читаем реестром контейнеров. - List boardIds = await _dbContext.Containers - .AsNoTracking() - .Where(container => container.Kind == ContainerKinds.Board) - .Select(container => container.Id) - .ToListAsync(ct); - - return await _dbContext.Cards - .AsNoTracking() - .Where(card => card.ReceivedAt < receivedBeforeUtc - && (card.Col == CardIds.Inbox || boardIds.Contains(card.Col))) - .Select(card => card.Id) - .ToListAsync(ct); - } - - /// - public async Task ArchiveAsync(IReadOnlyList cardIds, DateTimeOffset archivedAt, CancellationToken ct) - { - // Автоархив тика пачкой (tick_storage L462–473): один UPDATE вместо N по-карточных — как по-карточный - // UpdateColumnAsync автоархива: col=archive, is_new=false, archived_at=now, matchHits пусто; - // prev_col не трогается (Ruling 8). Возврат — сколько строк обновлено. - if (cardIds.Count == 0) - { - return 0; - } - - return await _dbContext.Cards - .Where(card => cardIds.Contains(card.Id)) - .ExecuteUpdateAsync(setters => setters - .SetProperty(card => card.Col, CardIds.Archive) - .SetProperty(card => card.IsNew, false) - .SetProperty(card => card.ArchivedAt, archivedAt) - .SetProperty(card => card.MatchHitsJson, ToJson(Array.Empty())), - ct); - } - - /// - public async Task> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct) - { - // Очистка архива: col='archive' и archived_at старше срока (tick_storage L475–478). - return await _dbContext.Cards - .AsNoTracking() - .Where(card => card.Col == CardIds.Archive - && card.ArchivedAt != null - && card.ArchivedAt < archivedBeforeUtc) - .Select(card => card.Id) - .ToListAsync(ct); - } - - /// - public async Task> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct) - { - // Очистка корзины: col='trash' и received_at старше срока (tick_storage L480–483). - return await _dbContext.Cards - .AsNoTracking() - .Where(card => card.Col == CardIds.Trash && card.ReceivedAt < receivedBeforeUtc) - .Select(card => card.Id) - .ToListAsync(ct); - } - - /// - public async Task PurgeAsync(IReadOnlyList cardIds, CancellationToken ct) - { - // Жёсткое удаление пачки (очистки тика и clear-col, Ruling 8): комментарии чистит каскад БД, - // строки дедупа удаляемых карточек — здесь же в транзакции (Ruling 3, _hard_delete L229). - if (cardIds.Count == 0) - { - return 0; - } - - await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct); - await _dbContext.DedupEntries - .Where(entry => entry.LeadId != null && cardIds.Contains(entry.LeadId!)) - .ExecuteDeleteAsync(ct); - int deleted = await _dbContext.Cards - .Where(card => cardIds.Contains(card.Id)) - .ExecuteDeleteAsync(ct); - await transaction.CommitAsync(ct); - return deleted; - } - - /// - public async Task> ListCardsForConversionAsync(CancellationToken ct) - { - // Карточки с бюджетом вне служебных колонок (recompute_conversions L106–130, Ruling 7): - // пересчёт читает только бюджетные/conv-поля, комментарии не нужны. - List entities = await _dbContext.Cards - .AsNoTracking() - .Where(card => card.BudgetCur != string.Empty - && !ConversionExcludedCols.Contains(card.Col)) - .OrderByDescending(card => card.ReceivedAt) - .ToListAsync(ct); - return entities.Select(card => ToCardDto(card, Array.Empty())).ToList(); - } - - /// - public async Task UpdateConversionAsync(string cardId, double? convFrom, double? convTo, string convCur, CancellationToken ct) - { - // Пишутся только conv-поля карточки (Ruling 7); convCur пуст — конверсия снята. - await _dbContext.Cards - .Where(card => card.Id == cardId) - .ExecuteUpdateAsync(setters => setters - .SetProperty(card => card.ConvFrom, convFrom) - .SetProperty(card => card.ConvTo, convTo) - .SetProperty(card => card.ConvCur, convCur), - ct); - } - - /// - public async Task> ListInboxWithSourceAsync(CancellationToken ct) - { - // Вход эвристики suggest (Ruling 3): «Неразобранное» с непустым source_msg — только тексты нужны. - List entities = await _dbContext.Cards - .AsNoTracking() - .Where(card => card.Col == CardIds.Inbox && card.SourceMsg != string.Empty) - .OrderByDescending(card => card.ReceivedAt) - .ToListAsync(ct); - return entities.Select(card => ToCardDto(card, Array.Empty())).ToList(); - } - -} +using System.Text.Json; +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Cards.Application.Abstractions; +using Deal.Modules.Cards.Application.Dtos; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// Правила хранения, конверсии и вход эвристики suggest — partial-часть +/// (C32: выделено из общего файла, поведение не менялось): кандидаты тика (автоархив/очистка архива и +/// корзины), batch-архивация и жёсткое удаление пачки, пересчёт бюджетных конверсий и «Неразобранное» +/// с исходным текстом для ИИ-предложений (tick_storage/recompute_conversions/suggest, Rulings 3/7/8). +/// +public sealed partial class KanbanStore +{ + /// + public async Task> ListArchiveCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct) + { + // Кандидаты автоархива: карточки пользовательских колонок (kind=board) и «Неразобранного» + // (tick_storage L462–467) — колонки динамические, поэтому id колонок читаем реестром контейнеров. + List boardIds = await _dbContext.Containers + .AsNoTracking() + .Where(container => container.Kind == ContainerKinds.Board) + .Select(container => container.Id) + .ToListAsync(ct); + + return await _dbContext.Cards + .AsNoTracking() + .Where(card => card.ReceivedAt < receivedBeforeUtc + && (card.Col == CardIds.Inbox || boardIds.Contains(card.Col))) + .Select(card => card.Id) + .ToListAsync(ct); + } + + /// + public async Task ArchiveAsync(IReadOnlyList cardIds, DateTimeOffset archivedAt, CancellationToken ct) + { + // Автоархив тика пачкой (tick_storage L462–473): один UPDATE вместо N по-карточных — как по-карточный + // UpdateColumnAsync автоархива: col=archive, is_new=false, archived_at=now, matchHits пусто; + // prev_col не трогается (Ruling 8). Возврат — сколько строк обновлено. + if (cardIds.Count == 0) + { + return 0; + } + + return await _dbContext.Cards + .Where(card => cardIds.Contains(card.Id)) + .ExecuteUpdateAsync(setters => setters + .SetProperty(card => card.Col, CardIds.Archive) + .SetProperty(card => card.IsNew, false) + .SetProperty(card => card.ArchivedAt, archivedAt) + .SetProperty(card => card.MatchHitsJson, ToJson(Array.Empty())), + ct); + } + + /// + public async Task> ListExpiredArchiveCandidatesAsync(DateTimeOffset archivedBeforeUtc, CancellationToken ct) + { + // Очистка архива: col='archive' и archived_at старше срока (tick_storage L475–478). + return await _dbContext.Cards + .AsNoTracking() + .Where(card => card.Col == CardIds.Archive + && card.ArchivedAt != null + && card.ArchivedAt < archivedBeforeUtc) + .Select(card => card.Id) + .ToListAsync(ct); + } + + /// + public async Task> ListTrashCandidatesAsync(DateTimeOffset receivedBeforeUtc, CancellationToken ct) + { + // Очистка корзины: col='trash' и received_at старше срока (tick_storage L480–483). + return await _dbContext.Cards + .AsNoTracking() + .Where(card => card.Col == CardIds.Trash && card.ReceivedAt < receivedBeforeUtc) + .Select(card => card.Id) + .ToListAsync(ct); + } + + /// + public async Task PurgeAsync(IReadOnlyList cardIds, CancellationToken ct) + { + // Жёсткое удаление пачки (очистки тика и clear-col, Ruling 8): комментарии чистит каскад БД, + // строки дедупа удаляемых карточек — здесь же в транзакции (Ruling 3, _hard_delete L229). + if (cardIds.Count == 0) + { + return 0; + } + + await using var transaction = await _dbContext.Database.BeginTransactionAsync(ct); + await _dbContext.DedupEntries + .Where(entry => entry.LeadId != null && cardIds.Contains(entry.LeadId!)) + .ExecuteDeleteAsync(ct); + int deleted = await _dbContext.Cards + .Where(card => cardIds.Contains(card.Id)) + .ExecuteDeleteAsync(ct); + await transaction.CommitAsync(ct); + return deleted; + } + + /// + public async Task> ListCardsForConversionAsync(CancellationToken ct) + { + // Карточки с бюджетом вне служебных колонок (recompute_conversions L106–130, Ruling 7): + // пересчёт читает только бюджетные/conv-поля, комментарии не нужны. + List entities = await _dbContext.Cards + .AsNoTracking() + .Where(card => card.BudgetCur != string.Empty + && !ConversionExcludedCols.Contains(card.Col)) + .OrderByDescending(card => card.ReceivedAt) + .ToListAsync(ct); + return entities.Select(card => ToCardDto(card, Array.Empty())).ToList(); + } + + /// + public async Task UpdateConversionAsync(string cardId, double? convFrom, double? convTo, string convCur, CancellationToken ct) + { + // Пишутся только conv-поля карточки (Ruling 7); convCur пуст — конверсия снята. + await _dbContext.Cards + .Where(card => card.Id == cardId) + .ExecuteUpdateAsync(setters => setters + .SetProperty(card => card.ConvFrom, convFrom) + .SetProperty(card => card.ConvTo, convTo) + .SetProperty(card => card.ConvCur, convCur), + ct); + } + + /// + public async Task> ListInboxWithSourceAsync(CancellationToken ct) + { + // Вход эвристики suggest (Ruling 3): «Неразобранное» с непустым source_msg — только тексты нужны. + List entities = await _dbContext.Cards + .AsNoTracking() + .Where(card => card.Col == CardIds.Inbox && card.SourceMsg != string.Empty) + .OrderByDescending(card => card.ReceivedAt) + .ToListAsync(ct); + return entities.Select(card => ToCardDto(card, Array.Empty())).ToList(); + } + +} diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs index 7d90a70..61a35cd 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/KanbanStore.cs @@ -1,320 +1,323 @@ -using System.Text.Json; -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер хранилища карточек и контейнеров (Ruling 1/12): таблицы Cards/Containers/LeadComments/CardMoves схемы тенанта. -/// -/// -/// Маппинг DTO ↔ строк выполняется вручную (эталон SettingsStore.cs): порт модуля не видит EF-сущности. -/// JSON-поля (KeywordsJson/VisibleFieldsJson/RulesJson, StackJson/ContactsJson/MatchHitsJson) хранятся текстом -/// с сериализованным JSON camelCase (конвенция value_json) — адаптер сериализует при записи и разбирает при чтении. -/// Времена — timestamptz (DateTimeOffset); наружу карточки отдают receivedAt epoch-ms (ToUnixTimeMilliseconds), -/// а human-метку «time» адаптер считает на лету от ReceivedAt/CreatedAt (Ruling 10, pipeline.py human_age L528–537). -/// Чтения — AsNoTracking; сортировки (received_at DESC и т.п.) — здесь, как требует порт. Id записей генерирует -/// модуль (Ruling 12) — адаптер только сохраняет готовые id. Транзакции: одиночная запись — SaveChanges -/// (ExecuteUpdate/ExecuteDelete — одним statement'ом); методы с несколькими изменениями (DeleteContainerAsync: -/// карточки + доска; ReorderContainersAsync: позиции всех досок) обёрнуты в явную транзакцию. -/// Удаление карточки чистит комментарии каскадом БД (FK LeadComments → Cards, Ruling 1); журнал CardMoves -/// и MlOutbox при удалении карточек не трогаются (прототип _hard_delete). Строки DedupEntries карточки -/// (LeadId = id карточки) удаляются вместе с ней в той же транзакции (Ruling 3) — «сирота» не должна -/// блокировать повторное создание карточки; таблицы соседние в том же TenantDbContext, порт Pipeline -/// не привлекается (циклов модулей нет). -/// C32: класс разделён на partial-файлы по темам (KanbanStore.Containers/Cards/Selected/Comments/StorageRules.cs); -/// поведение, сигнатуры и тексты ошибок не менялись. -/// -public sealed partial class KanbanStore : ICardStore -{ - private readonly TenantDbContext _dbContext; - - /// - /// Создаёт EF-адаптер хранилища канбана (зависимости — tenant-контекст БД). - /// - /// Контекст схемы тенанта (Containers/Cards/LeadComments/CardMoves/DedupEntries). - public KanbanStore(TenantDbContext dbContext) - { - _dbContext = dbContext; - } - - // JSON-представление «правил нет» (в DTO — null Rules, Ruling 1: как value_json настроек). - private const string NoRulesJson = "{}"; - - // Действие журнала CardMoves «перенос» (источник few-shot-примеров, python move). - private const string MoveAction = "move"; - - // Действие журнала CardMoves «возврат из корзины/архива» (python restore). - private const string RestoreAction = "restore"; - - private const long MillisPerMinute = 60_000L; - private const long MinutesPerHour = 60L; - private const long HoursPerDay = 24L; - - // Колонки, исключённые из пересчёта конверсий (recompute_conversions L106–130, Ruling 7). - private static readonly string[] ConversionExcludedCols = - [CardIds.Archive, CardIds.Trash]; - - // Id контейнеров-стадий пространства «Выбранные»: дашборд эти карточки не показывает (этап 9). - private static readonly string[] SelectedStageIds = CardsDefaultContainers.Ids.ToArray(); - - // Стадия «Отложено» — единственная, чьи напоминания проверяет проверка напоминаний (Ruling 3). - private const string HoldStage = CardsDefaultContainers.Hold; - - // Опции JSON: camelCase для записей модуля Kanban (1:1 с wire-именами §4.1/§4.2). - private static readonly JsonSerializerOptions JsonOptions = new() - { - PropertyNamingPolicy = JsonNamingPolicy.CamelCase, - PropertyNameCaseInsensitive = true, - }; - - // Читает карточки пачкой с приложенными комментариями (один запрос на все id). - // cards: Карточки (уже отсортированы запросом чтения). - // ct: Токен отмены. - // Возвращает: DTO карточек в порядке входа: JSON-поля разобраны, комментарии приложены, time посчитан. - private async Task> ToCardDtosAsync(List cards, CancellationToken ct) - { - if (cards.Count == 0) - { - return Array.Empty(); - } - - List cardIds = cards.Select(card => card.Id).ToList(); - List comments = await _dbContext.LeadComments - .AsNoTracking() - .Where(comment => cardIds.Contains(comment.CardId)) - .OrderBy(comment => comment.CreatedAt) - .ThenBy(comment => comment.Id) - .ToListAsync(ct); - ILookup commentsByCardId = comments.ToLookup(comment => comment.CardId); - - return cards - .Select(card => ToCardDto(card, commentsByCardId[card.Id].Select(ToCommentDto).ToList())) - .ToList(); - } - - // Маппинг строки Containers в DTO (JSON-поля разбираются). - private static ContainerDto ToContainerDto(ContainerEntity entity) => new() - { - Id = entity.Id, - Name = entity.Name, - Description = entity.Description, - Color = entity.Color, - Order = entity.Position, - Space = entity.Space, - Kind = entity.Kind, - Collapsed = entity.Collapsed, - Suggested = entity.Suggested, - Rules = ToRulesOrNull(entity.RulesJson), - Policy = ToPolicyOrEmpty(entity.PolicyJson), - Note = entity.Note, - }; - - // Маппинг DTO в строку Containers (JSON-поля сериализуются; CreatedAt — UTC-now). - private static ContainerEntity ToContainerEntity(ContainerDto container) => new() - { - Id = container.Id, - Name = container.Name, - Description = container.Description, - Color = container.Color, - Position = container.Order, - Space = container.Space, - Kind = container.Kind, - Collapsed = container.Collapsed, - Suggested = container.Suggested, - RulesJson = ToRulesJson(container.Rules), - PolicyJson = ToPolicyJson(container.Policy), - Note = container.Note, - CreatedAt = DateTimeOffset.UtcNow, - }; - - // Маппинг строки Cards в DTO: JSON-поля разобраны, budget/converted собраны из пар (from,to,cur). - // entity: Строка карточки. - // comments: Комментарии карточки (уже в DTO с human-меткой time). - private static CardDto ToCardDto(CardEntity entity, IReadOnlyList comments) => new() - { - Id = entity.Id, - Col = entity.Col, - IsNew = entity.IsNew, - Local = entity.Local, - IsVacancy = entity.IsVacancy, - IsVacancyKnown = entity.IsVacancyKnown, - Title = entity.Title, - Summary = entity.Summary, - Source = new CardSourceDto( - Kind: ResolveSourceKind(entity), - DisplayName: entity.ChannelName, - OriginRef: entity.SourceDialogId, - ReceivedAt: entity.ReceivedAt.ToUnixTimeMilliseconds()), - Stack = ToJsonList(entity.StackJson), - Budget = entity.BudgetCur.Length == 0 - ? null - : new CardBudgetDto(entity.BudgetFrom, entity.BudgetTo, entity.BudgetCur), - Converted = entity.ConvCur.Length == 0 - ? null - : new CardBudgetDto(entity.ConvFrom, entity.ConvTo, entity.ConvCur), - Contact = entity.Contact, - Contacts = ToJsonList(entity.ContactsJson), - Channel = new CardChannelDto(entity.ChannelName, entity.ChannelHandle, entity.ChannelHue), - Time = HumanAge(entity.ReceivedAt), - ReceivedAtMs = entity.ReceivedAt.ToUnixTimeMilliseconds(), - SourceMsg = entity.SourceMsg, - SourceDialogId = entity.SourceDialogId, - SourceMsgId = entity.SourceMsgId, - PrevCol = entity.PrevCol, - MatchHits = ToJsonList(entity.MatchHitsJson), - Comments = comments, - Links = ToJsonList(entity.LinksJson), - Files = ToJsonList(entity.FilesJson), - History = ToJsonList(entity.HistoryJson), - TzText = entity.TzText, - Reminder = entity.ReminderAt is { } reminderAt ? new CardReminderDto(reminderAt.ToUnixTimeMilliseconds()) : null, - CreatedAtMs = entity.CreatedAt.ToUnixTimeMilliseconds(), - UpdatedAtMs = entity.UpdatedAt.ToUnixTimeMilliseconds(), - }; - - // Вид источника карточки (производная проекция): local/telegram/other. - // entity: Строка карточки. - // Возвращает: Вид источника (строка wire-контракта). - private static string ResolveSourceKind(CardEntity entity) - { - if (entity.Local) - { - return "local"; - } - - return entity.ChannelName.Length > 0 || entity.SourceDialogId.Length > 0 ? "telegram" : "other"; - } - - // Маппинг снимка (write-модель создания карточки) в строку Cards; CreatedAt — UTC-now. - private static CardEntity ToCardEntity(CardSnapshot snapshot) => new() - { - Id = snapshot.Id, - Col = snapshot.Col, - IsNew = snapshot.IsNew, - Local = snapshot.Local, - IsVacancy = snapshot.IsVacancy, - IsVacancyKnown = snapshot.IsVacancyKnown, - Title = snapshot.Title, - Summary = snapshot.Summary, - StackJson = ToJson(snapshot.Stack), - BudgetFrom = snapshot.BudgetFrom, - BudgetTo = snapshot.BudgetTo, - BudgetCur = snapshot.BudgetCur, - ConvFrom = snapshot.ConvFrom, - ConvTo = snapshot.ConvTo, - ConvCur = snapshot.ConvCur, - Contact = snapshot.Contact, - ContactsJson = ToJson(snapshot.Contacts), - ChannelName = snapshot.ChannelName, - ChannelHandle = snapshot.ChannelHandle, - ChannelHue = snapshot.ChannelHue, - ReceivedAt = snapshot.ReceivedAt, - SourceMsg = snapshot.SourceMsg, - SourceDialogId = snapshot.SourceDialogId, - SourceMsgId = snapshot.SourceMsgId, - PrevCol = snapshot.PrevCol, - ArchivedAt = snapshot.ArchivedAt, - MatchHitsJson = ToJson(snapshot.MatchHits), - TzText = snapshot.TzText, - HistoryJson = ToJson(snapshot.History), - CreatedAt = DateTimeOffset.UtcNow, - UpdatedAt = DateTimeOffset.UtcNow, - }; - - // Маппинг строки LeadComments в DTO (human-метка time от CreatedAt, Ruling 10). - private static CardCommentDto ToCommentDto(LeadCommentEntity entity) => - new(entity.Id, entity.By, entity.Text, HumanAge(entity.CreatedAt)); - - // Человеческая метка возраста: «только что»/«N мин»/«N ч»/«N дн» (Ruling 10, pipeline.py human_age L528–537). - // timestamp: Время события (получения сообщения/добавления комментария). - // Возвращает: Метка от текущего UTC-момента; будущее время даёт «только что» (delta зажат в 0, как в прототипе). - private static string HumanAge(DateTimeOffset timestamp) - { - long deltaMs = Math.Max(0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - timestamp.ToUnixTimeMilliseconds()); - long minutes = deltaMs / MillisPerMinute; - if (minutes < MinutesPerHour) - { - return minutes < 1 ? CardsService.JustNowLabel : $"{minutes} мин"; - } - - long hours = minutes / MinutesPerHour; - if (hours < HoursPerDay) - { - return $"{hours} ч"; - } - - long days = hours / HoursPerDay; - return $"{days} дн"; - } - - // Разбирает JSON-массив в список; пустая/битая строка — пустой список (как json.loads в прототипе). - private static IReadOnlyList ToJsonList(string json) - { - if (string.IsNullOrWhiteSpace(json)) - { - return Array.Empty(); - } - - try - { - return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; - } - catch (JsonException) - { - return Array.Empty(); - } - } - - // Разбирает rules-объект: {}/пустая/битая строка — null («правил нет»). - private static ContainerRulesDto? ToRulesOrNull(string json) - { - if (string.IsNullOrWhiteSpace(json) || json == NoRulesJson) - { - return null; - } - - try - { - return JsonSerializer.Deserialize(json, JsonOptions); - } - catch (JsonException) - { - return null; - } - } - - // Сериализует rules-объект: null («правил нет») хранится как {}. - private static string ToRulesJson(ContainerRulesDto? rules) => - rules is null ? NoRulesJson : JsonSerializer.Serialize(rules, JsonOptions); - - // Разбирает policy-объект: пустая/битая строка — политика по умолчанию (обычная колонка). - private static ContainerPolicyDto ToPolicyOrEmpty(string json) - { - if (string.IsNullOrWhiteSpace(json) || json == NoRulesJson) - { - return new ContainerPolicyDto(); - } - - try - { - return JsonSerializer.Deserialize(json, JsonOptions) ?? new ContainerPolicyDto(); - } - catch (JsonException) - { - return new ContainerPolicyDto(); - } - } - - // Сериализует policy-объект в JSON (camelCase, конвенция value_json). - private static string ToPolicyJson(ContainerPolicyDto policy) => JsonSerializer.Serialize(policy, JsonOptions); - - // Сериализует значение в JSON (camelCase, конвенция value_json). - private static string ToJson(T value) => JsonSerializer.Serialize(value, JsonOptions); -} - +using System.Text.Json; +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Cards.Application.Abstractions; +using Deal.Modules.Cards.Application.Dtos; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// EF-адаптер хранилища карточек и контейнеров (Ruling 1/12): таблицы Cards/Containers/LeadComments/CardMoves схемы тенанта. +/// +/// +/// Маппинг DTO ↔ строк выполняется вручную (эталон SettingsStore.cs): порт модуля не видит EF-сущности. +/// JSON-поля (KeywordsJson/VisibleFieldsJson/RulesJson, StackJson/ContactsJson/MatchHitsJson) хранятся текстом +/// с сериализованным JSON camelCase (конвенция value_json) — адаптер сериализует при записи и разбирает при чтении. +/// Времена — timestamptz (DateTimeOffset); наружу карточки отдают receivedAt epoch-ms (ToUnixTimeMilliseconds), +/// а human-метку «time» адаптер считает на лету от ReceivedAt/CreatedAt (Ruling 10, pipeline.py human_age L528–537). +/// Чтения — AsNoTracking; сортировки (received_at DESC и т.п.) — здесь, как требует порт. Id записей генерирует +/// модуль (Ruling 12) — адаптер только сохраняет готовые id. Транзакции: одиночная запись — SaveChanges +/// (ExecuteUpdate/ExecuteDelete — одним statement'ом); методы с несколькими изменениями (DeleteContainerAsync: +/// карточки + доска; ReorderContainersAsync: позиции всех досок) обёрнуты в явную транзакцию. +/// Удаление карточки чистит комментарии каскадом БД (FK LeadComments → Cards, Ruling 1); журнал CardMoves +/// и MlOutbox при удалении карточек не трогаются (прототип _hard_delete). Строки DedupEntries карточки +/// (LeadId = id карточки) удаляются вместе с ней в той же транзакции (Ruling 3) — «сирота» не должна +/// блокировать повторное создание карточки; таблицы соседние в том же TenantDbContext, порт Pipeline +/// не привлекается (циклов модулей нет). +/// C32: класс разделён на partial-файлы по темам (KanbanStore.Containers/Cards/Selected/Comments/StorageRules.cs); +/// поведение, сигнатуры и тексты ошибок не менялись. +/// +public sealed partial class KanbanStore : ICardStore +{ + private readonly TenantDbContext _dbContext; + + /// + /// Создаёт EF-адаптер хранилища канбана (зависимости — tenant-контекст БД). + /// + /// Контекст схемы тенанта (Containers/Cards/LeadComments/CardMoves/DedupEntries). + public KanbanStore(TenantDbContext dbContext) + { + _dbContext = dbContext; + } + + // JSON-представление «правил нет» (в DTO — null Rules, Ruling 1: как value_json настроек). + private const string NoRulesJson = "{}"; + + // Действие журнала CardMoves «перенос» (источник few-shot-примеров, python move). + private const string MoveAction = "move"; + + // Действие журнала CardMoves «возврат из корзины/архива» (python restore). + private const string RestoreAction = "restore"; + + private const long MillisPerMinute = 60_000L; + private const long MinutesPerHour = 60L; + private const long HoursPerDay = 24L; + + // Колонки, исключённые из пересчёта конверсий (recompute_conversions L106–130, Ruling 7). + private static readonly string[] ConversionExcludedCols = + [CardIds.Archive, CardIds.Trash]; + + // Id контейнеров-стадий пространства «Выбранные»: дашборд эти карточки не показывает (этап 9). + private static readonly string[] SelectedStageIds = CardsDefaultContainers.Ids.ToArray(); + + // Стадия «Отложено» — единственная, чьи напоминания проверяет проверка напоминаний (Ruling 3). + private const string HoldStage = CardsDefaultContainers.Hold; + + // Опции JSON: camelCase для записей модуля Kanban (1:1 с wire-именами §4.1/§4.2). + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + PropertyNameCaseInsensitive = true, + }; + + // Читает карточки пачкой с приложенными комментариями (один запрос на все id). + // cards: Карточки (уже отсортированы запросом чтения). + // ct: Токен отмены. + // Возвращает: DTO карточек в порядке входа: JSON-поля разобраны, комментарии приложены, time посчитан. + private async Task> ToCardDtosAsync(List cards, CancellationToken ct) + { + if (cards.Count == 0) + { + return Array.Empty(); + } + + List cardIds = cards.Select(card => card.Id).ToList(); + List comments = await _dbContext.LeadComments + .AsNoTracking() + .Where(comment => cardIds.Contains(comment.CardId)) + .OrderBy(comment => comment.CreatedAt) + .ThenBy(comment => comment.Id) + .ToListAsync(ct); + ILookup commentsByCardId = comments.ToLookup(comment => comment.CardId); + + return cards + .Select(card => ToCardDto(card, commentsByCardId[card.Id].Select(ToCommentDto).ToList())) + .ToList(); + } + + // Маппинг строки Containers в DTO (JSON-поля разбираются). + private static ContainerDto ToContainerDto(ContainerEntity entity) => new() + { + Id = entity.Id, + Name = entity.Name, + Description = entity.Description, + Color = entity.Color, + Order = entity.Position, + Space = entity.Space, + Kind = entity.Kind, + Collapsed = entity.Collapsed, + Suggested = entity.Suggested, + Rules = ToRulesOrNull(entity.RulesJson), + Policy = ToPolicyOrEmpty(entity.PolicyJson), + Note = entity.Note, + }; + + // Маппинг DTO в строку Containers (JSON-поля сериализуются; CreatedAt — UTC-now). + private static ContainerEntity ToContainerEntity(ContainerDto container) => new() + { + Id = container.Id, + Name = container.Name, + Description = container.Description, + Color = container.Color, + Position = container.Order, + Space = container.Space, + Kind = container.Kind, + Collapsed = container.Collapsed, + Suggested = container.Suggested, + RulesJson = ToRulesJson(container.Rules), + PolicyJson = ToPolicyJson(container.Policy), + Note = container.Note, + CreatedAt = DateTimeOffset.UtcNow, + }; + + // Маппинг строки Cards в DTO: JSON-поля разобраны, budget/converted собраны из пар (from,to,cur). + // entity: Строка карточки. + // comments: Комментарии карточки (уже в DTO с human-меткой time). + private static CardDto ToCardDto(CardEntity entity, IReadOnlyList comments) => new() + { + Id = entity.Id, + Col = entity.Col, + IsNew = entity.IsNew, + Local = entity.Local, + IsVacancy = entity.IsVacancy, + IsVacancyKnown = entity.IsVacancyKnown, + Title = entity.Title, + Summary = entity.Summary, + Source = new CardSourceDto( + Kind: ResolveSourceKind(entity), + DisplayName: entity.ChannelName, + OriginRef: entity.SourceDialogId, + ReceivedAt: entity.ReceivedAt.ToUnixTimeMilliseconds()), + Stack = ToJsonList(entity.StackJson), + Budget = entity.BudgetCur.Length == 0 + ? null + : new CardBudgetDto(entity.BudgetFrom, entity.BudgetTo, entity.BudgetCur), + Converted = entity.ConvCur.Length == 0 + ? null + : new CardBudgetDto(entity.ConvFrom, entity.ConvTo, entity.ConvCur), + Contact = entity.Contact, + Contacts = ToJsonList(entity.ContactsJson), + Channel = new CardChannelDto(entity.ChannelName, entity.ChannelHandle, entity.ChannelHue), + Time = HumanAge(entity.ReceivedAt), + ReceivedAtMs = entity.ReceivedAt.ToUnixTimeMilliseconds(), + SourceMsg = entity.SourceMsg, + SourceDialogId = entity.SourceDialogId, + SourceMsgId = entity.SourceMsgId, + PrevCol = entity.PrevCol, + MatchHits = ToJsonList(entity.MatchHitsJson), + Comments = comments, + Links = ToJsonList(entity.LinksJson), + Files = ToJsonList(entity.FilesJson), + History = ToJsonList(entity.HistoryJson), + TzText = entity.TzText, + Reminder = entity.ReminderAt is { } reminderAt ? new CardReminderDto(reminderAt.ToUnixTimeMilliseconds()) : null, + CreatedAtMs = entity.CreatedAt.ToUnixTimeMilliseconds(), + UpdatedAtMs = entity.UpdatedAt.ToUnixTimeMilliseconds(), + }; + + // Вид источника карточки (производная проекция): local/telegram/other. + // entity: Строка карточки. + // Возвращает: Вид источника (строка wire-контракта). + private static string ResolveSourceKind(CardEntity entity) + { + if (entity.Local) + { + return "local"; + } + + return entity.ChannelName.Length > 0 || entity.SourceDialogId.Length > 0 ? "telegram" : "other"; + } + + // Маппинг снимка (write-модель создания карточки) в строку Cards; CreatedAt — UTC-now. + private static CardEntity ToCardEntity(CardSnapshot snapshot) => new() + { + Id = snapshot.Id, + Col = snapshot.Col, + IsNew = snapshot.IsNew, + Local = snapshot.Local, + IsVacancy = snapshot.IsVacancy, + IsVacancyKnown = snapshot.IsVacancyKnown, + Title = snapshot.Title, + Summary = snapshot.Summary, + StackJson = ToJson(snapshot.Stack), + BudgetFrom = snapshot.BudgetFrom, + BudgetTo = snapshot.BudgetTo, + BudgetCur = snapshot.BudgetCur, + ConvFrom = snapshot.ConvFrom, + ConvTo = snapshot.ConvTo, + ConvCur = snapshot.ConvCur, + Contact = snapshot.Contact, + ContactsJson = ToJson(snapshot.Contacts), + ChannelName = snapshot.ChannelName, + ChannelHandle = snapshot.ChannelHandle, + ChannelHue = snapshot.ChannelHue, + ReceivedAt = snapshot.ReceivedAt, + SourceMsg = snapshot.SourceMsg, + SourceDialogId = snapshot.SourceDialogId, + SourceMsgId = snapshot.SourceMsgId, + PrevCol = snapshot.PrevCol, + ArchivedAt = snapshot.ArchivedAt, + MatchHitsJson = ToJson(snapshot.MatchHits), + TzText = snapshot.TzText, + HistoryJson = ToJson(snapshot.History), + CreatedAt = DateTimeOffset.UtcNow, + UpdatedAt = DateTimeOffset.UtcNow, + }; + + // Маппинг строки LeadComments в DTO (human-метка time от CreatedAt, Ruling 10). + private static CardCommentDto ToCommentDto(LeadCommentEntity entity) => + new(entity.Id, entity.By, entity.Text, HumanAge(entity.CreatedAt)); + + // Человеческая метка возраста: «только что»/«N мин»/«N ч»/«N дн» (Ruling 10, pipeline.py human_age L528–537). + // timestamp: Время события (получения сообщения/добавления комментария). + // Возвращает: Метка от текущего UTC-момента; будущее время даёт «только что» (delta зажат в 0, как в прототипе). + private static string HumanAge(DateTimeOffset timestamp) + { + long deltaMs = Math.Max(0, DateTimeOffset.UtcNow.ToUnixTimeMilliseconds() - timestamp.ToUnixTimeMilliseconds()); + long minutes = deltaMs / MillisPerMinute; + if (minutes < MinutesPerHour) + { + return minutes < 1 ? CardsService.JustNowLabel : $"{minutes} мин"; + } + + long hours = minutes / MinutesPerHour; + if (hours < HoursPerDay) + { + return $"{hours} ч"; + } + + long days = hours / HoursPerDay; + return $"{days} дн"; + } + + // Разбирает JSON-массив в список; пустая/битая строка — пустой список (как json.loads в прототипе). + private static IReadOnlyList ToJsonList(string json) + { + if (string.IsNullOrWhiteSpace(json)) + { + return Array.Empty(); + } + + try + { + return JsonSerializer.Deserialize>(json, JsonOptions) ?? []; + } + catch (JsonException) + { + return Array.Empty(); + } + } + + // Разбирает rules-объект: {}/пустая/битая строка — null («правил нет»). + private static ContainerRulesDto? ToRulesOrNull(string json) + { + if (string.IsNullOrWhiteSpace(json) || json == NoRulesJson) + { + return null; + } + + try + { + return JsonSerializer.Deserialize(json, JsonOptions); + } + catch (JsonException) + { + return null; + } + } + + // Сериализует rules-объект: null («правил нет») хранится как {}. + private static string ToRulesJson(ContainerRulesDto? rules) => + rules is null ? NoRulesJson : JsonSerializer.Serialize(rules, JsonOptions); + + // Разбирает policy-объект: пустая/битая строка — политика по умолчанию (обычная колонка). + private static ContainerPolicyDto ToPolicyOrEmpty(string json) + { + if (string.IsNullOrWhiteSpace(json) || json == NoRulesJson) + { + return new ContainerPolicyDto(); + } + + try + { + return JsonSerializer.Deserialize(json, JsonOptions) ?? new ContainerPolicyDto(); + } + catch (JsonException) + { + return new ContainerPolicyDto(); + } + } + + // Сериализует policy-объект в JSON (camelCase, конвенция value_json). + private static string ToPolicyJson(ContainerPolicyDto policy) => JsonSerializer.Serialize(policy, JsonOptions); + + // Сериализует значение в JSON (camelCase, конвенция value_json). + private static string ToJson(T value) => JsonSerializer.Serialize(value, JsonOptions); +} + diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/MlLearningStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/MlLearningStore.cs index 0bb1f57..f934f8f 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/MlLearningStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/MlLearningStore.cs @@ -1,6 +1,9 @@ using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/OperatorAuthStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/OperatorAuthStore.cs index e7912c1..b8e5758 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/OperatorAuthStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/OperatorAuthStore.cs @@ -1,6 +1,9 @@ using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/PipelineStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/PipelineStore.cs index a5b98aa..dbe3439 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/PipelineStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/PipelineStore.cs @@ -1,7 +1,13 @@ using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Pipeline.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/RateLimitCounterStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/RateLimitCounterStore.cs index a60252e..b657a60 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/RateLimitCounterStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/RateLimitCounterStore.cs @@ -2,7 +2,11 @@ using System.Data; using System.Data.Common; using System.Globalization; using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/SettingsStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/SettingsStore.cs index c8abd7c..a8c7db1 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/SettingsStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/SettingsStore.cs @@ -1,6 +1,8 @@ using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/TenantLimitStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/TenantLimitStore.cs index ed9fa41..4262d04 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/TenantLimitStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/TenantLimitStore.cs @@ -1,6 +1,9 @@ using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/TenantRepository.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/TenantRepository.cs index a3a6c33..068b91b 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/TenantRepository.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/TenantRepository.cs @@ -1,6 +1,9 @@ using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Persistence.Repositories; diff --git a/src/core/Deal.Infrastructure/Persistence/Repositories/TokenUsageEventStore.cs b/src/core/Deal.Infrastructure/Persistence/Repositories/TokenUsageEventStore.cs index 5254fe6..ab66c0c 100644 --- a/src/core/Deal.Infrastructure/Persistence/Repositories/TokenUsageEventStore.cs +++ b/src/core/Deal.Infrastructure/Persistence/Repositories/TokenUsageEventStore.cs @@ -1,183 +1,186 @@ -using System.Linq.Expressions; -using Deal.Infrastructure.Persistence.Entities; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Infrastructure.Persistence.Repositories; - -/// -/// EF-адаптер истории расхода токенов: таблица public.token_usage_events (append-only + агрегаты, этап 10, T2). -/// -/// -/// Маппинг DTO ↔ сущности вручную (порт модуля не видит EF-сущности, Ruling 1). Запись — только Add+SaveChanges. -/// Агрегация — group by день UTC (Year/Month/Day), тенант, провайдер или модель; фильтры TenantId/Provider/Model/ -/// Kind/At-range. Порядок: day — по возрастанию даты, остальные — по убыванию total (детерминизм витрины). -/// Неизвестный groupBy — (HTTP-слой валидирует и отвечает 400 раньше). -/// -public sealed class TokenUsageEventStore(DealDbContext dbContext) : ITokenUsageEventStore -{ - /// - public async Task AppendAsync(TokenUsageEventDto record, CancellationToken ct) - { - dbContext.TokenUsageEvents.Add(new TokenUsageEventEntity - { - // Id генерирует БД (identity) — из DTO не копируется. - TenantId = record.TenantId, - At = record.At, - Provider = record.Provider, - Model = record.Model, - Kind = record.Kind, - PromptTokens = record.PromptTokens, - CompletionTokens = record.CompletionTokens, - TotalTokens = record.TotalTokens, - DetailJson = record.DetailJson, - }); - await dbContext.SaveChangesAsync(ct); - } - - /// - public async Task> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct) - { - IQueryable source = ApplyFilters(dbContext.TokenUsageEvents.AsNoTracking(), query); - return query.GroupBy switch - { - TokenUsageGroupBys.Day => await AggregateByDayAsync(source, ct), - TokenUsageGroupBys.Tenant => await AggregateByTenantAsync(source, ct), - TokenUsageGroupBys.Provider => await AggregateByStringAsync(source, entity => entity.Provider, ct), - TokenUsageGroupBys.Model => await AggregateByStringAsync(source, entity => entity.Model, ct), - _ => throw new ArgumentException($"Неизвестная группировка расхода токенов: '{query.GroupBy}'.", nameof(query)), - }; - } - - // Применяет фильтры агрегации (TenantId/Provider/Model/Kind/At-range). - // source: Базовый запрос. - // query: Фильтр/группировка. - // Возвращает: Запрос с фильтрами. - private static IQueryable ApplyFilters(IQueryable source, TokenUsageEventQueryDto query) - { - if (query.TenantId is not null) - { - source = source.Where(e => e.TenantId == query.TenantId); - } - - if (!string.IsNullOrWhiteSpace(query.Provider)) - { - source = source.Where(e => e.Provider == query.Provider); - } - - if (!string.IsNullOrWhiteSpace(query.Model)) - { - source = source.Where(e => e.Model == query.Model); - } - - if (!string.IsNullOrWhiteSpace(query.Kind)) - { - source = source.Where(e => e.Kind == query.Kind); - } - - if (query.From is not null) - { - source = source.Where(e => e.At >= query.From.Value); - } - - if (query.To is not null) - { - source = source.Where(e => e.At <= query.To.Value); - } - - return source; - } - - // Агрегат по суткам UTC (ключ ГГГГ-ММ-ДД), порядок — по возрастанию даты. - // source: Отфильтрованный запрос. - // ct: Токен отмены. - // Возвращает: Строки агрегатов по дням. - private static async Task> AggregateByDayAsync( - IQueryable source, CancellationToken ct) - { - var rows = await source - .GroupBy(e => new { e.At.Year, e.At.Month, e.At.Day }) - .Select(group => new - { - group.Key.Year, - group.Key.Month, - group.Key.Day, - Prompt = group.Sum(x => x.PromptTokens), - Completion = group.Sum(x => x.CompletionTokens), - Total = group.Sum(x => x.TotalTokens), - Count = group.LongCount(), - }) - .ToListAsync(ct); - - return rows - .OrderBy(row => row.Year) - .ThenBy(row => row.Month) - .ThenBy(row => row.Day) - .Select(row => new TokenUsageAggregateDto( - $"{row.Year:D4}-{row.Month:D2}-{row.Day:D2}", - row.Prompt, - row.Completion, - row.Total, - row.Count)) - .ToList(); - } - - // Агрегат по тенантам (ключ — Guid "D"), порядок — по убыванию total. - // source: Отфильтрованный запрос. - // ct: Токен отмены. - // Возвращает: Строки агрегатов по тенантам. - private static async Task> AggregateByTenantAsync( - IQueryable source, CancellationToken ct) - { - var rows = await source - .GroupBy(e => e.TenantId) - .Select(group => new - { - group.Key, - Prompt = group.Sum(x => x.PromptTokens), - Completion = group.Sum(x => x.CompletionTokens), - Total = group.Sum(x => x.TotalTokens), - Count = group.LongCount(), - }) - .ToListAsync(ct); - - return rows - .OrderByDescending(row => row.Total) - .Select(row => new TokenUsageAggregateDto( - row.Key.ToString("D"), - row.Prompt, - row.Completion, - row.Total, - row.Count)) - .ToList(); - } - - // Агрегат по строковому ключу (провайдер/модель), порядок — по убыванию total. - // source: Отфильтрованный запрос. - // keySelector: Селектор ключа группы (провайдер/модель). - // ct: Токен отмены. - // Возвращает: Строки агрегатов по ключу. - private static async Task> AggregateByStringAsync( - IQueryable source, - Expression> keySelector, - CancellationToken ct) - { - var rows = await source - .GroupBy(keySelector) - .Select(group => new - { - Key = group.Key, - Prompt = group.Sum(x => x.PromptTokens), - Completion = group.Sum(x => x.CompletionTokens), - Total = group.Sum(x => x.TotalTokens), - Count = group.LongCount(), - }) - .ToListAsync(ct); - - return rows - .OrderByDescending(row => row.Total) - .Select(row => new TokenUsageAggregateDto(row.Key, row.Prompt, row.Completion, row.Total, row.Count)) - .ToList(); - } -} +using System.Linq.Expressions; +using Deal.Infrastructure.Persistence.Entities; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Infrastructure.Persistence.Repositories; + +/// +/// EF-адаптер истории расхода токенов: таблица public.token_usage_events (append-only + агрегаты, этап 10, T2). +/// +/// +/// Маппинг DTO ↔ сущности вручную (порт модуля не видит EF-сущности, Ruling 1). Запись — только Add+SaveChanges. +/// Агрегация — group by день UTC (Year/Month/Day), тенант, провайдер или модель; фильтры TenantId/Provider/Model/ +/// Kind/At-range. Порядок: day — по возрастанию даты, остальные — по убыванию total (детерминизм витрины). +/// Неизвестный groupBy — (HTTP-слой валидирует и отвечает 400 раньше). +/// +public sealed class TokenUsageEventStore(DealDbContext dbContext) : ITokenUsageEventStore +{ + /// + public async Task AppendAsync(TokenUsageEventDto record, CancellationToken ct) + { + dbContext.TokenUsageEvents.Add(new TokenUsageEventEntity + { + // Id генерирует БД (identity) — из DTO не копируется. + TenantId = record.TenantId, + At = record.At, + Provider = record.Provider, + Model = record.Model, + Kind = record.Kind, + PromptTokens = record.PromptTokens, + CompletionTokens = record.CompletionTokens, + TotalTokens = record.TotalTokens, + DetailJson = record.DetailJson, + }); + await dbContext.SaveChangesAsync(ct); + } + + /// + public async Task> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct) + { + IQueryable source = ApplyFilters(dbContext.TokenUsageEvents.AsNoTracking(), query); + return query.GroupBy switch + { + TokenUsageGroupBys.Day => await AggregateByDayAsync(source, ct), + TokenUsageGroupBys.Tenant => await AggregateByTenantAsync(source, ct), + TokenUsageGroupBys.Provider => await AggregateByStringAsync(source, entity => entity.Provider, ct), + TokenUsageGroupBys.Model => await AggregateByStringAsync(source, entity => entity.Model, ct), + _ => throw new ArgumentException($"Неизвестная группировка расхода токенов: '{query.GroupBy}'.", nameof(query)), + }; + } + + // Применяет фильтры агрегации (TenantId/Provider/Model/Kind/At-range). + // source: Базовый запрос. + // query: Фильтр/группировка. + // Возвращает: Запрос с фильтрами. + private static IQueryable ApplyFilters(IQueryable source, TokenUsageEventQueryDto query) + { + if (query.TenantId is not null) + { + source = source.Where(e => e.TenantId == query.TenantId); + } + + if (!string.IsNullOrWhiteSpace(query.Provider)) + { + source = source.Where(e => e.Provider == query.Provider); + } + + if (!string.IsNullOrWhiteSpace(query.Model)) + { + source = source.Where(e => e.Model == query.Model); + } + + if (!string.IsNullOrWhiteSpace(query.Kind)) + { + source = source.Where(e => e.Kind == query.Kind); + } + + if (query.From is not null) + { + source = source.Where(e => e.At >= query.From.Value); + } + + if (query.To is not null) + { + source = source.Where(e => e.At <= query.To.Value); + } + + return source; + } + + // Агрегат по суткам UTC (ключ ГГГГ-ММ-ДД), порядок — по возрастанию даты. + // source: Отфильтрованный запрос. + // ct: Токен отмены. + // Возвращает: Строки агрегатов по дням. + private static async Task> AggregateByDayAsync( + IQueryable source, CancellationToken ct) + { + var rows = await source + .GroupBy(e => new { e.At.Year, e.At.Month, e.At.Day }) + .Select(group => new + { + group.Key.Year, + group.Key.Month, + group.Key.Day, + Prompt = group.Sum(x => x.PromptTokens), + Completion = group.Sum(x => x.CompletionTokens), + Total = group.Sum(x => x.TotalTokens), + Count = group.LongCount(), + }) + .ToListAsync(ct); + + return rows + .OrderBy(row => row.Year) + .ThenBy(row => row.Month) + .ThenBy(row => row.Day) + .Select(row => new TokenUsageAggregateDto( + $"{row.Year:D4}-{row.Month:D2}-{row.Day:D2}", + row.Prompt, + row.Completion, + row.Total, + row.Count)) + .ToList(); + } + + // Агрегат по тенантам (ключ — Guid "D"), порядок — по убыванию total. + // source: Отфильтрованный запрос. + // ct: Токен отмены. + // Возвращает: Строки агрегатов по тенантам. + private static async Task> AggregateByTenantAsync( + IQueryable source, CancellationToken ct) + { + var rows = await source + .GroupBy(e => e.TenantId) + .Select(group => new + { + group.Key, + Prompt = group.Sum(x => x.PromptTokens), + Completion = group.Sum(x => x.CompletionTokens), + Total = group.Sum(x => x.TotalTokens), + Count = group.LongCount(), + }) + .ToListAsync(ct); + + return rows + .OrderByDescending(row => row.Total) + .Select(row => new TokenUsageAggregateDto( + row.Key.ToString("D"), + row.Prompt, + row.Completion, + row.Total, + row.Count)) + .ToList(); + } + + // Агрегат по строковому ключу (провайдер/модель), порядок — по убыванию total. + // source: Отфильтрованный запрос. + // keySelector: Селектор ключа группы (провайдер/модель). + // ct: Токен отмены. + // Возвращает: Строки агрегатов по ключу. + private static async Task> AggregateByStringAsync( + IQueryable source, + Expression> keySelector, + CancellationToken ct) + { + var rows = await source + .GroupBy(keySelector) + .Select(group => new + { + Key = group.Key, + Prompt = group.Sum(x => x.PromptTokens), + Completion = group.Sum(x => x.CompletionTokens), + Total = group.Sum(x => x.TotalTokens), + Count = group.LongCount(), + }) + .ToListAsync(ct); + + return rows + .OrderByDescending(row => row.Total) + .Select(row => new TokenUsageAggregateDto(row.Key, row.Prompt, row.Completion, row.Total, row.Count)) + .ToList(); + } +} diff --git a/src/core/Deal.Infrastructure/Security/AesGcmSecretCipher.cs b/src/core/Deal.Infrastructure/Security/AesGcmSecretCipher.cs index 8aedb6c..9020268 100644 --- a/src/core/Deal.Infrastructure/Security/AesGcmSecretCipher.cs +++ b/src/core/Deal.Infrastructure/Security/AesGcmSecretCipher.cs @@ -1,6 +1,9 @@ using System.Security.Cryptography; using System.Text; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Infrastructure.Security; diff --git a/src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs b/src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs index 2dd3fd4..0abe311 100644 --- a/src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs +++ b/src/core/Deal.Infrastructure/ServiceCollectionExtensions.cs @@ -1,267 +1,285 @@ -using Deal.Contracts.Integrations; -using Deal.Infrastructure.Integrations; -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Repositories; -using Deal.Infrastructure.Security; -using Deal.Infrastructure.Services; -using Deal.Infrastructure.Tenancy; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Discovery.Application; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Pipeline.Application; -using Deal.Modules.Settings.Application; -using Deal.Modules.Telegram.Application; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Tenants; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Deal.Infrastructure; - -/// -/// DI-регистрация адаптеров персистентности Deal.Infrastructure (паттерн «port & adapter», Ruling 1). -/// -/// -/// Регистрируются только EF-адаптеры портов модулей. Сервисы самих модулей регистрируют -/// модульные регистраторы (AddTenantsModule в Deal.Api). Адаптеры — scoped, потому что живут -/// на scoped-контекстах EF (DealDbContext), которыми владеет запрос. -/// -public static class ServiceCollectionExtensions -{ - /// - /// Регистрирует EF-адаптеры портов модулей: IAuthStore, IOperatorAuthStore, ITenantRepository, IAuditLogStore, IInviteStore, ITenantLimitStore, ISettingsStore, IGlobalSettingsStore, ICardStore, IPipelineStore, IMlLearningStore, ITenantProvisioner. - /// - /// Коллекция сервисов. - /// Дефолт-бюджет лениво создаваемых строк tenant_limits (Ruling 3; null — константа TokenBudgetDefaults). - /// Передаётся из конфигурации/env DEAL_DEFAULT_AI_BUDGET в Program.cs (Task 8). - /// Коллекция сервисов для цепочки вызовов. - public static IServiceCollection AddDealPersistence( - this IServiceCollection services, TokenLimitDefaults? tenantLimitDefaults = null) - { - services.AddScoped(); - - // Хранилище аутентификации оператора (Ruling 1 этапа 7): таблицы operators/operator_sessions - // схемы public — отдельный порт от IAuthStore (оператор ≠ пользователь тенанта). - services.AddScoped(); - services.AddScoped(); - - // Хранилище аудита (Ruling 4): таблица public.audit_log — append-only, порт без Update/Delete. - services.AddScoped(); - - // Хранилище истории расхода токенов (этап 10, T2): таблица public.token_usage_events — append-only - // + агрегаты для аналитики; порт без Update/Delete. - services.AddScoped(); - - // Хранилище приглашений (Ruling 2): таблица public.invites — создание/чтение/отзыв оператором (Task 5), - // статусные переходы меняет прикладной слой (InvitesService), активацию выполнит /api/join (Task 6). - services.AddScoped(); - - // Хранилище лимитов ИИ-бюджета (Ruling 3, Task 8): таблица public.tenant_limits. Фабрика передаёт - // дефолт-бюджет из конфигурации (Program.cs) — ленивый GetOrCreate новой строки использует его; - // DealDbContext уже зарегистрирован в Api (AddDbContext до AddDealPersistence). - services.AddScoped(provider => new TenantLimitStore( - provider.GetRequiredService(), - tenantLimitDefaults ?? TokenBudgetDefaults.Default)); - - // Хранилище счётчиков фиксированного окна (этап 12, пакет B): таблица public.rate_limit_counters — - // общее хранилище распределённого rate limiting (auth/api/gRPC) и учёта попыток входа. Scoped - // (DealDbContext); лимитер резолвит его в собственном scope на каждое приобретение. - services.AddScoped(); - - // KV-хранилище настроек тенанта (Ruling 1): таблица settings в схеме тенанта, - // контекст — scoped TenantDbContext запроса (см. AddDbContext в Deal.Api). - services.AddScoped(); - - // KV-хранилище глобальных (системных) настроек оператора (ТЗ §4.1/§8.1): таблица - // public.global_settings, контекст — системный DealDbContext (public-схема). Ключи Telegram - // задаёт оператор, ядро читает их для команд входа (TelegramKeysService). - services.AddScoped(); - - // Хранилище карточек и контейнеров (Ruling 12): таблицы Cards/Containers/LeadComments/CardMoves в схеме тенанта. - services.AddScoped(); - - // Хранилище пайплайна (Ruling 10): таблицы QueueItems/RejectedItems/DedupEntries в схеме тенанта. - // KanbanStore при жёстком удалении карточки чистит DedupEntries напрямую тем же TenantDbContext - // (Ruling 3) — IPipelineStore для этого не привлекается, цикла Kanban → Pipeline нет. - services.AddScoped(); - - // Хранилище обучения ML (Ruling 4, Task 5): очередь MlOutbox + счётчик журнала CardMoves. - services.AddScoped(); - - // Хранилище каталога диалогов Telegram (Ruling 7, Task 13): таблицы Dialogs/TgMessages в схеме тенанта. - services.AddScoped(); - - // Хранилище Discovery (Ruling 9, Task 17): таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog в схеме - // тенанта. Проверки «уже мониторится» читают таблицу Dialogs (владелец — Telegram) тем же TenantDbContext. - services.AddScoped(); - - // Провижининг схем тенантов (Ruling 3). TenantProvisioningService зависит от - // ConnectionStringProvider — он регистрируется в Deal.Api (Program.cs) как singleton. - services.AddScoped(); - - // Пакетная (maintenance) миграция схем всех тенантов (этап 12, пакет C): ограниченный - // параллелизм + логирование прогресса поверх ITenantRepository и ITenantProvisioner. - services.AddScoped(); - - // Единая точка перехода карточки между контейнерами (R4 этапа 9): выбор маршрута «стадия - // Выбранных vs дашборд-контейнер» живёт в адаптере, а не в эндпоинте. Scoped — - // композирует scoped-сервис карточек в рамках tenant-запроса. - services.AddScoped(); - return services; - } - - /// - /// Регистрирует адаптеры внешних интеграций (Rulings 4/5/6/9): IMlClient, IAiClassifier, IAiTools, IColumnSuggester, ITelegramGateway. - /// - /// Коллекция сервисов. - /// Конфигурация секции Services:Ml — выбор реализации IMlClient (Ruling 6). - /// Конфигурация секции Services:Ai — выбор реализации IAiClassifier/IAiTools (Ruling 6). - /// Конфигурация секции Services:Telegram — выбор реализации ITelegramGateway (Ruling 6). - /// Коллекция сервисов для цепочки вызовов. - /// - /// IMlClient: при UseLocal=true (default) — Local-заглушка LocalMlClient (фолбэк этапов 2–5); при - /// UseLocal=false — gRPC-адаптер GrpcMlClient (ml.proto, Ruling 1/4) + синглтон-транспорт - /// MlGrpcConnection и кэш статуса MlStatusCache; тот же адаптер реализует IMlTrainClient для фонового - /// MlOutboxFlushScheduler (регистрируется в Deal.Api под тем же флагом). PushAsync в обоих режимах пишет - /// в MlOutbox (Ruling 6 — обучение всегда локально), выгрузку батчами делает флашер. IAiClassifier: при - /// Services:Ai:UseLocal=true — LocalAiClassifier (детерминированный разбор ядра, Ruling 5 этапа 4); - /// при false — GrpcAiClassifier (ai.proto; Ruling 5/6) + синглтон AiGrpcConnection (fail-fast, как - /// MlGrpcConnection) + scoped-сервисы конфига провайдера/учёта токенов. Бюджетный гейт (Task 9, Ruling 3): - /// в gRPC-режиме порты наружу отдаются декораторами BudgetedAiClassifier/BudgetedAiTools поверх gRPC-адаптеров - /// (порядок Grpc → Budgeted), Local-реализации регистрируются как бесплатный fallback гейта. IAiTools: - /// LocalAiTools (методы не поддерживаются — NotSupportedException, Ruling 9) либо GrpcAiTools за тем же флагом. - /// IColumnSuggester — - /// детерминированная Local-эвристика LocalColumnSuggester (Self-Review плана L525–527: на gRPC сознательно - /// не заменяется). ITelegramGateway: при Services:Telegram:UseLocal=true (default) — LocalTelegramGateway - /// (нейтральный no-op/idle, фолбэк до Task 14); при false — GrpcTelegramClient (telegram.proto, Ruling 7) + - /// синглтон-транспорт TelegramGrpcConnection (fail-fast). - /// Scoped: адаптеры читают KV-настройки тенанта (ISettingsStore → scoped TenantDbContext запроса) и - /// пишут в таблицы схемы тенанта, поэтому не могут жить дольше scope. HTTP-адаптеры других - /// интеграций (IAiConnectionChecker/IRatesSource) регистрируются в Deal.Api через AddHttpClient — - /// см. Program.cs (Tasks 6/8). - /// - public static IServiceCollection AddDealIntegrations( - this IServiceCollection services, MlServiceOptions mlOptions, AiServiceOptions aiOptions, - TelegramServiceOptions telegramOptions, MtlsCertificates? mtlsCertificates = null) - { - // Сертификаты mTLS-каналов (Ruling 6, Task 13): null (флаг DEAL_MTLS_ENABLED выключен) — каналы - // остаются plaintext + service-token (dev); при включённом флаге каждый транспорт подписывает запрос - // клиентским сертификатом и проверяет CA сервера (fail-fast на загрузку — в MtlsCertificates.Load). - // Recorder расхода токенов (Ruling 3 этапа 7; история — этап 10, T2): пишет бюджет/lifetime/историю - // событий. Регистрируется независимо от AI-режима — им пользуется и gRPC ML-клиент (событие kind=ml). - services.AddScoped(); - - // ML-клиент (Ruling 6, план Task 16): выбор на старте — Local-заглушка либо gRPC-адаптер ml-service. - if (mlOptions.UseLocal) - { - // Обучение копится в MlOutbox (этап 3); отправка в ML-сервис не выполняется (сервиса нет в dev) — - // очередь остаётся накопленной, как в прототипе при недоступном сервисе (python L56–82). - services.AddScoped(); - } - else - { - // gRPC-клиент ml-service: транспорт создаётся сразу (fail-fast: пустой endpoint/токен останавливают - // старт — Ruling 2/13), кэш статуса 15 с переживает scope запросов. Scoped GrpcMlClient читает - // KV/таблицы тенанта (как LocalMlClient) и ходит в сервис по metadata tenant-id/service-token. - services.AddSingleton(new MlGrpcConnection(mlOptions, mtlsCertificates)); - services.AddSingleton(); - services.AddScoped(); - services.AddScoped(provider => provider.GetRequiredService()); - services.AddScoped(provider => provider.GetRequiredService()); - } - - // ИИ-предложения колонок/ключей (Ruling 3, Task 14): порт Contracts → детерминированная эвристика. - // LocalColumnSuggester читает карточки через ICardStore, считает группы ядром SuggestHeuristics - // (модуль Kanban) и создаёт доски suggested=true через ContainersService; scoped — его зависимости - // живут в рамках tenant-запроса (KanbanStore/TenantDbContext). На этапе 6 адаптер заменяется - // gRPC-клиентом ai-service с тем же контрактом. - services.AddScoped(); - - // ИИ-классификатор входящих (Ruling 5/6, Task 15): порт Contracts → детерминированная Local-реализация - // (UseLocal=true) либо gRPC-адаптер ai-service (UseLocal=false). LocalAiClassifier разбирает сообщение - // ядром LocalFieldsParser модуля Pipeline (маркерная гипотеза типа: is_vacancy_known=false, board=null — - // «смысловые колонки до ИИ не назначаем») и всегда пропускает ИИ-фильтр {pass:true, skipped:true}; - // scoped — LocalFieldsParser читает маркеры hireMarkers/levelTerms из KV-настроек тенанта (ISettingsStore). - // GrpcAiClassifier строит промпты/контекст классификации (AiClassifyContextBuilder модуля Pipeline, - // регистрируется AddPipelineModule), ходит в ai-service с ProviderConfig из настроек (расшифровка apiKey) - // и копит usage в KV aiTokenUsage; недоступность/ok=false → AiUnavailableException — воркер падает в - // локальный разбор (aiFail, как raw={} python L1108–1114). Выбор на старте, рантайм-логики нет (Ruling 6). - // Списывание в tenant_limits + lifetime-KV aiTokenUsage выполняет TokenUsageRecorder (Ruling 3, Task 8). - if (aiOptions.UseLocal) - { - services.AddScoped(); - services.AddScoped(); - } - else - { - // gRPC-клиент ai-service: транспорт создаётся сразу (fail-fast: пустой endpoint/токен останавливают - // старт — Ruling 2/13), как MlGrpcConnection. Scoped-адаптеры читают настройки/данные тенанта и ходят - // в сервис по metadata tenant-id/service-token; usage ответов списывает TokenUsageRecorder - // (tenant_limits + lifetime-KV aiTokenUsage, Ruling 3 этапа 7). - services.AddSingleton(new AiGrpcConnection(aiOptions, mtlsCertificates)); - services.AddScoped(); - services.AddScoped(); - // Локальные адаптеры как fallback бюджетного гейта (Ruling 3, Task 9): даже в gRPC-режиме декоратор при - // исчерпанном бюджете/приостановке уводит вызов на детерминированный бесплатный локальный разбор - // (LocalAiClassifier; LocalFieldsParser регистрирует AddPipelineModule в Program.cs). Инструменты локального - // fallback не имеют — при запрете BudgetedAiTools бросает AiUnavailableException/мягкую ошибку (Ruling 3). - services.AddScoped(); - // Декораторы бюджетного гейта (порядок Grpc → Budgeted → наружу): перед каждым платным вызовом - // GetStateAsync (ITenantLimitStore, scoped) — исчерпано/приостановлено → Local-классификатор либо - // исключение/мягкая ошибка инструментов; списание usage остаётся внутри gRPC-адаптеров (Task 8). - services.AddScoped(provider => new BudgetedAiClassifier( - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService>())); - services.AddScoped(); - services.AddScoped(provider => new BudgetedAiTools( - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService>())); - } - - // Гейт telegram-service (Ruling 6/7, план Task 14): порт Contracts. По умолчанию (UseLocal=true) — - // локальная заглушка dev LocalTelegramGateway (нейтральный no-op/idle — сервис не поднят); при - // UseLocal=false — gRPC-клиент GrpcTelegramClient (telegram.proto) + синглтон-транспорт - // TelegramGrpcConnection (fail-fast, как MlGrpcConnection). Scoped-адаптер: tenant-id для metadata - // берёт из ITenantContext (AsyncLocal) scope вызова (HTTP-запрос/tenant-циклы ингресса). - if (telegramOptions.UseLocal) - { - services.AddSingleton(); - } - else - { - services.AddSingleton(new TelegramGrpcConnection(telegramOptions, mtlsCertificates)); - services.AddScoped(); - services.AddScoped(provider => provider.GetRequiredService()); - } - - return services; - } - - /// - /// Регистрирует сервисы шифрования секретов: ISecretCipher → AesGcmSecretCipher (Ruling 2). - /// - /// Коллекция сервисов. - /// ContentRoot приложения — каталог по умолчанию для файла-ключа data/encryption.key. - /// Коллекция сервисов для цепочки вызовов. - /// - /// Ключ разрешается один раз при вызове: невалидный DEAL_ENCRYPTION_KEY → исключение при старте - /// (план Task 1). EncryptionKeyProvider в контейнер не регистрируется — после разрешения ключа - /// он рантайм-сервисам не нужен. ISecretCipher — singleton: реализация без разделяемого - /// состояния (AesGcm создаётся на операцию), поэтому потокобезопасна. - /// - public static IServiceCollection AddDealSecurity(this IServiceCollection services, string contentRootPath) - { - EncryptionKeyProvider keyProvider = new(contentRootPath); - byte[] key = keyProvider.GetKey(); - services.AddSingleton(new AesGcmSecretCipher(key)); - return services; - } -} +using Deal.Contracts.Integrations; +using Deal.Infrastructure.Integrations; +using Deal.Infrastructure.Persistence; +using Deal.Infrastructure.Persistence.Repositories; +using Deal.Infrastructure.Security; +using Deal.Infrastructure.Services; +using Deal.Infrastructure.Tenancy; +using Deal.Modules.Cards.Application.Abstractions; +using Deal.Modules.Cards.Application.Dtos; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Telegram.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; +using Deal.SharedKernel.Tenants; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Deal.Infrastructure; + +/// +/// DI-регистрация адаптеров персистентности Deal.Infrastructure (паттерн «port & adapter», Ruling 1). +/// +/// +/// Регистрируются только EF-адаптеры портов модулей. Сервисы самих модулей регистрируют +/// модульные регистраторы (AddTenantsModule в Deal.Api). Адаптеры — scoped, потому что живут +/// на scoped-контекстах EF (DealDbContext), которыми владеет запрос. +/// +public static class ServiceCollectionExtensions +{ + /// + /// Регистрирует EF-адаптеры портов модулей: IAuthStore, IOperatorAuthStore, ITenantRepository, IAuditLogStore, IInviteStore, ITenantLimitStore, ISettingsStore, IGlobalSettingsStore, ICardStore, IPipelineStore, IMlLearningStore, ITenantProvisioner. + /// + /// Коллекция сервисов. + /// Дефолт-бюджет лениво создаваемых строк tenant_limits (Ruling 3; null — константа TokenBudgetDefaults). + /// Передаётся из конфигурации/env DEAL_DEFAULT_AI_BUDGET в Program.cs (Task 8). + /// Коллекция сервисов для цепочки вызовов. + public static IServiceCollection AddDealPersistence( + this IServiceCollection services, TokenLimitDefaults? tenantLimitDefaults = null) + { + services.AddScoped(); + + // Хранилище аутентификации оператора (Ruling 1 этапа 7): таблицы operators/operator_sessions + // схемы public — отдельный порт от IAuthStore (оператор ≠ пользователь тенанта). + services.AddScoped(); + services.AddScoped(); + + // Хранилище аудита (Ruling 4): таблица public.audit_log — append-only, порт без Update/Delete. + services.AddScoped(); + + // Хранилище истории расхода токенов (этап 10, T2): таблица public.token_usage_events — append-only + // + агрегаты для аналитики; порт без Update/Delete. + services.AddScoped(); + + // Хранилище приглашений (Ruling 2): таблица public.invites — создание/чтение/отзыв оператором (Task 5), + // статусные переходы меняет прикладной слой (InvitesService), активацию выполнит /api/join (Task 6). + services.AddScoped(); + + // Хранилище лимитов ИИ-бюджета (Ruling 3, Task 8): таблица public.tenant_limits. Фабрика передаёт + // дефолт-бюджет из конфигурации (Program.cs) — ленивый GetOrCreate новой строки использует его; + // DealDbContext уже зарегистрирован в Api (AddDbContext до AddDealPersistence). + services.AddScoped(provider => new TenantLimitStore( + provider.GetRequiredService(), + tenantLimitDefaults ?? TokenBudgetDefaults.Default)); + + // Хранилище счётчиков фиксированного окна (этап 12, пакет B): таблица public.rate_limit_counters — + // общее хранилище распределённого rate limiting (auth/api/gRPC) и учёта попыток входа. Scoped + // (DealDbContext); лимитер резолвит его в собственном scope на каждое приобретение. + services.AddScoped(); + + // KV-хранилище настроек тенанта (Ruling 1): таблица settings в схеме тенанта, + // контекст — scoped TenantDbContext запроса (см. AddDbContext в Deal.Api). + services.AddScoped(); + + // KV-хранилище глобальных (системных) настроек оператора (ТЗ §4.1/§8.1): таблица + // public.global_settings, контекст — системный DealDbContext (public-схема). Ключи Telegram + // задаёт оператор, ядро читает их для команд входа (TelegramKeysService). + services.AddScoped(); + + // Хранилище карточек и контейнеров (Ruling 12): таблицы Cards/Containers/LeadComments/CardMoves в схеме тенанта. + services.AddScoped(); + + // Хранилище пайплайна (Ruling 10): таблицы QueueItems/RejectedItems/DedupEntries в схеме тенанта. + // KanbanStore при жёстком удалении карточки чистит DedupEntries напрямую тем же TenantDbContext + // (Ruling 3) — IPipelineStore для этого не привлекается, цикла Kanban → Pipeline нет. + services.AddScoped(); + + // Хранилище обучения ML (Ruling 4, Task 5): очередь MlOutbox + счётчик журнала CardMoves. + services.AddScoped(); + + // Хранилище каталога диалогов Telegram (Ruling 7, Task 13): таблицы Dialogs/TgMessages в схеме тенанта. + services.AddScoped(); + + // Хранилище Discovery (Ruling 9, Task 17): таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog в схеме + // тенанта. Проверки «уже мониторится» читают таблицу Dialogs (владелец — Telegram) тем же TenantDbContext. + services.AddScoped(); + + // Провижининг схем тенантов (Ruling 3). TenantProvisioningService зависит от + // ConnectionStringProvider — он регистрируется в Deal.Api (Program.cs) как singleton. + services.AddScoped(); + + // Пакетная (maintenance) миграция схем всех тенантов (этап 12, пакет C): ограниченный + // параллелизм + логирование прогресса поверх ITenantRepository и ITenantProvisioner. + services.AddScoped(); + + // Единая точка перехода карточки между контейнерами (R4 этапа 9): выбор маршрута «стадия + // Выбранных vs дашборд-контейнер» живёт в адаптере, а не в эндпоинте. Scoped — + // композирует scoped-сервис карточек в рамках tenant-запроса. + services.AddScoped(); + return services; + } + + /// + /// Регистрирует адаптеры внешних интеграций (Rulings 4/5/6/9): IMlClient, IAiClassifier, IAiTools, IColumnSuggester, ITelegramGateway. + /// + /// Коллекция сервисов. + /// Конфигурация секции Services:Ml — выбор реализации IMlClient (Ruling 6). + /// Конфигурация секции Services:Ai — выбор реализации IAiClassifier/IAiTools (Ruling 6). + /// Конфигурация секции Services:Telegram — выбор реализации ITelegramGateway (Ruling 6). + /// Коллекция сервисов для цепочки вызовов. + /// + /// IMlClient: при UseLocal=true (default) — Local-заглушка LocalMlClient (фолбэк этапов 2–5); при + /// UseLocal=false — gRPC-адаптер GrpcMlClient (ml.proto, Ruling 1/4) + синглтон-транспорт + /// MlGrpcConnection и кэш статуса MlStatusCache; тот же адаптер реализует IMlTrainClient для фонового + /// MlOutboxFlushScheduler (регистрируется в Deal.Api под тем же флагом). PushAsync в обоих режимах пишет + /// в MlOutbox (Ruling 6 — обучение всегда локально), выгрузку батчами делает флашер. IAiClassifier: при + /// Services:Ai:UseLocal=true — LocalAiClassifier (детерминированный разбор ядра, Ruling 5 этапа 4); + /// при false — GrpcAiClassifier (ai.proto; Ruling 5/6) + синглтон AiGrpcConnection (fail-fast, как + /// MlGrpcConnection) + scoped-сервисы конфига провайдера/учёта токенов. Бюджетный гейт (Task 9, Ruling 3): + /// в gRPC-режиме порты наружу отдаются декораторами BudgetedAiClassifier/BudgetedAiTools поверх gRPC-адаптеров + /// (порядок Grpc → Budgeted), Local-реализации регистрируются как бесплатный fallback гейта. IAiTools: + /// LocalAiTools (методы не поддерживаются — NotSupportedException, Ruling 9) либо GrpcAiTools за тем же флагом. + /// IColumnSuggester — + /// детерминированная Local-эвристика LocalColumnSuggester (Self-Review плана L525–527: на gRPC сознательно + /// не заменяется). ITelegramGateway: при Services:Telegram:UseLocal=true (default) — LocalTelegramGateway + /// (нейтральный no-op/idle, фолбэк до Task 14); при false — GrpcTelegramClient (telegram.proto, Ruling 7) + + /// синглтон-транспорт TelegramGrpcConnection (fail-fast). + /// Scoped: адаптеры читают KV-настройки тенанта (ISettingsStore → scoped TenantDbContext запроса) и + /// пишут в таблицы схемы тенанта, поэтому не могут жить дольше scope. HTTP-адаптеры других + /// интеграций (IAiConnectionChecker/IRatesSource) регистрируются в Deal.Api через AddHttpClient — + /// см. Program.cs (Tasks 6/8). + /// + public static IServiceCollection AddDealIntegrations( + this IServiceCollection services, MlServiceOptions mlOptions, AiServiceOptions aiOptions, + TelegramServiceOptions telegramOptions, MtlsCertificates? mtlsCertificates = null) + { + // Сертификаты mTLS-каналов (Ruling 6, Task 13): null (флаг DEAL_MTLS_ENABLED выключен) — каналы + // остаются plaintext + service-token (dev); при включённом флаге каждый транспорт подписывает запрос + // клиентским сертификатом и проверяет CA сервера (fail-fast на загрузку — в MtlsCertificates.Load). + // Recorder расхода токенов (Ruling 3 этапа 7; история — этап 10, T2): пишет бюджет/lifetime/историю + // событий. Регистрируется независимо от AI-режима — им пользуется и gRPC ML-клиент (событие kind=ml). + services.AddScoped(); + + // ML-клиент (Ruling 6, план Task 16): выбор на старте — Local-заглушка либо gRPC-адаптер ml-service. + if (mlOptions.UseLocal) + { + // Обучение копится в MlOutbox (этап 3); отправка в ML-сервис не выполняется (сервиса нет в dev) — + // очередь остаётся накопленной, как в прототипе при недоступном сервисе (python L56–82). + services.AddScoped(); + } + else + { + // gRPC-клиент ml-service: транспорт создаётся сразу (fail-fast: пустой endpoint/токен останавливают + // старт — Ruling 2/13), кэш статуса 15 с переживает scope запросов. Scoped GrpcMlClient читает + // KV/таблицы тенанта (как LocalMlClient) и ходит в сервис по metadata tenant-id/service-token. + services.AddSingleton(new MlGrpcConnection(mlOptions, mtlsCertificates)); + services.AddSingleton(); + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService()); + services.AddScoped(provider => provider.GetRequiredService()); + } + + // ИИ-предложения колонок/ключей (Ruling 3, Task 14): порт Contracts → детерминированная эвристика. + // LocalColumnSuggester читает карточки через ICardStore, считает группы ядром SuggestHeuristics + // (модуль Kanban) и создаёт доски suggested=true через ContainersService; scoped — его зависимости + // живут в рамках tenant-запроса (KanbanStore/TenantDbContext). На этапе 6 адаптер заменяется + // gRPC-клиентом ai-service с тем же контрактом. + services.AddScoped(); + + // ИИ-классификатор входящих (Ruling 5/6, Task 15): порт Contracts → детерминированная Local-реализация + // (UseLocal=true) либо gRPC-адаптер ai-service (UseLocal=false). LocalAiClassifier разбирает сообщение + // ядром LocalFieldsParser модуля Pipeline (маркерная гипотеза типа: is_vacancy_known=false, board=null — + // «смысловые колонки до ИИ не назначаем») и всегда пропускает ИИ-фильтр {pass:true, skipped:true}; + // scoped — LocalFieldsParser читает маркеры hireMarkers/levelTerms из KV-настроек тенанта (ISettingsStore). + // GrpcAiClassifier строит промпты/контекст классификации (AiClassifyContextBuilder модуля Pipeline, + // регистрируется AddPipelineModule), ходит в ai-service с ProviderConfig из настроек (расшифровка apiKey) + // и копит usage в KV aiTokenUsage; недоступность/ok=false → AiUnavailableException — воркер падает в + // локальный разбор (aiFail, как raw={} python L1108–1114). Выбор на старте, рантайм-логики нет (Ruling 6). + // Списывание в tenant_limits + lifetime-KV aiTokenUsage выполняет TokenUsageRecorder (Ruling 3, Task 8). + if (aiOptions.UseLocal) + { + services.AddScoped(); + services.AddScoped(); + } + else + { + // gRPC-клиент ai-service: транспорт создаётся сразу (fail-fast: пустой endpoint/токен останавливают + // старт — Ruling 2/13), как MlGrpcConnection. Scoped-адаптеры читают настройки/данные тенанта и ходят + // в сервис по metadata tenant-id/service-token; usage ответов списывает TokenUsageRecorder + // (tenant_limits + lifetime-KV aiTokenUsage, Ruling 3 этапа 7). + services.AddSingleton(new AiGrpcConnection(aiOptions, mtlsCertificates)); + services.AddScoped(); + services.AddScoped(); + // Локальные адаптеры как fallback бюджетного гейта (Ruling 3, Task 9): даже в gRPC-режиме декоратор при + // исчерпанном бюджете/приостановке уводит вызов на детерминированный бесплатный локальный разбор + // (LocalAiClassifier; LocalFieldsParser регистрирует AddPipelineModule в Program.cs). Инструменты локального + // fallback не имеют — при запрете BudgetedAiTools бросает AiUnavailableException/мягкую ошибку (Ruling 3). + services.AddScoped(); + // Декораторы бюджетного гейта (порядок Grpc → Budgeted → наружу): перед каждым платным вызовом + // GetStateAsync (ITenantLimitStore, scoped) — исчерпано/приостановлено → Local-классификатор либо + // исключение/мягкая ошибка инструментов; списание usage остаётся внутри gRPC-адаптеров (Task 8). + services.AddScoped(provider => new BudgetedAiClassifier( + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService>())); + services.AddScoped(); + services.AddScoped(provider => new BudgetedAiTools( + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService>())); + } + + // Гейт telegram-service (Ruling 6/7, план Task 14): порт Contracts. По умолчанию (UseLocal=true) — + // локальная заглушка dev LocalTelegramGateway (нейтральный no-op/idle — сервис не поднят); при + // UseLocal=false — gRPC-клиент GrpcTelegramClient (telegram.proto) + синглтон-транспорт + // TelegramGrpcConnection (fail-fast, как MlGrpcConnection). Scoped-адаптер: tenant-id для metadata + // берёт из ITenantContext (AsyncLocal) scope вызова (HTTP-запрос/tenant-циклы ингресса). + if (telegramOptions.UseLocal) + { + services.AddSingleton(); + } + else + { + services.AddSingleton(new TelegramGrpcConnection(telegramOptions, mtlsCertificates)); + services.AddScoped(); + services.AddScoped(provider => provider.GetRequiredService()); + } + + return services; + } + + /// + /// Регистрирует сервисы шифрования секретов: ISecretCipher → AesGcmSecretCipher (Ruling 2). + /// + /// Коллекция сервисов. + /// ContentRoot приложения — каталог по умолчанию для файла-ключа data/encryption.key. + /// Коллекция сервисов для цепочки вызовов. + /// + /// Ключ разрешается один раз при вызове: невалидный DEAL_ENCRYPTION_KEY → исключение при старте + /// (план Task 1). EncryptionKeyProvider в контейнер не регистрируется — после разрешения ключа + /// он рантайм-сервисам не нужен. ISecretCipher — singleton: реализация без разделяемого + /// состояния (AesGcm создаётся на операцию), поэтому потокобезопасна. + /// + public static IServiceCollection AddDealSecurity(this IServiceCollection services, string contentRootPath) + { + EncryptionKeyProvider keyProvider = new(contentRootPath); + byte[] key = keyProvider.GetKey(); + services.AddSingleton(new AesGcmSecretCipher(key)); + return services; + } +} diff --git a/src/core/Deal.Infrastructure/Services/CardMover.cs b/src/core/Deal.Infrastructure/Services/CardMover.cs index 134dee9..eb37acb 100644 --- a/src/core/Deal.Infrastructure/Services/CardMover.cs +++ b/src/core/Deal.Infrastructure/Services/CardMover.cs @@ -1,38 +1,41 @@ -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.Models; - -namespace Deal.Infrastructure.Services; - -/// -/// Единая точка перехода карточки между контейнерами — реализация порта -/// (R4 этапа 9). -/// -/// -/// Маршрутизация цели живёт в домене: цель-стадия пространства «Выбранные» -/// () переходит через — -/// запись истории движения и сброс напоминания; остальные цели (доски/служебные зоны) — штатный перенос -/// дашборда (журнал CardMoves, matchHits, обучение ML). -/// Адаптер только нормализует результаты обоих маршрутов к ; снимок карточки -/// эндпоинт перечитывает единым чтением. Scoped: зависимости живут в рамках tenant-запроса. -/// -/// Сервис карточек — единый домен перехода (стадии и контейнеры дашборда). -public sealed class CardMover(CardsService cardsService) : ICardMover -{ - /// - public async Task MoveAsync( - string cardId, string toContainerId, TransitionContext ctx, CancellationToken ct) - { - // Контекст перехода (инициатор/обучение) учтён внутри маршрутов: дашборд-перенос обучает ML по - // цели пользователя, переход по стадии пишет историю и сбрасывает напоминание. Отдельного - // ветвления по ctx сейчас нет — оно появится с политиками контейнеров (R4). - CardResultDto result = CardsDefaultContainers.Contains(toContainerId) - ? await cardsService.MoveStageCardAsync(cardId, toContainerId, ct) - : await cardsService.MoveDashboardCardAsync(cardId, toContainerId, ct); - return result.Error is not null - ? new CardMoveResultDto(result.Error, Exists: true) - : new CardMoveResultDto(null, Exists: result.Card is not null); - } -} +using Deal.Modules.Cards.Application.Abstractions; +using Deal.Modules.Cards.Application.Dtos; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; + +namespace Deal.Infrastructure.Services; + +/// +/// Единая точка перехода карточки между контейнерами — реализация порта +/// (R4 этапа 9). +/// +/// +/// Маршрутизация цели живёт в домене: цель-стадия пространства «Выбранные» +/// () переходит через — +/// запись истории движения и сброс напоминания; остальные цели (доски/служебные зоны) — штатный перенос +/// дашборда (журнал CardMoves, matchHits, обучение ML). +/// Адаптер только нормализует результаты обоих маршрутов к ; снимок карточки +/// эндпоинт перечитывает единым чтением. Scoped: зависимости живут в рамках tenant-запроса. +/// +/// Сервис карточек — единый домен перехода (стадии и контейнеры дашборда). +public sealed class CardMover(CardsService cardsService) : ICardMover +{ + /// + public async Task MoveAsync( + string cardId, string toContainerId, TransitionContext ctx, CancellationToken ct) + { + // Контекст перехода (инициатор/обучение) учтён внутри маршрутов: дашборд-перенос обучает ML по + // цели пользователя, переход по стадии пишет историю и сбрасывает напоминание. Отдельного + // ветвления по ctx сейчас нет — оно появится с политиками контейнеров (R4). + CardResultDto result = CardsDefaultContainers.Contains(toContainerId) + ? await cardsService.MoveStageCardAsync(cardId, toContainerId, ct) + : await cardsService.MoveDashboardCardAsync(cardId, toContainerId, ct); + return result.Error is not null + ? new CardMoveResultDto(result.Error, Exists: true) + : new CardMoveResultDto(null, Exists: result.Card is not null); + } +} diff --git a/src/core/Deal.Infrastructure/Tenancy/DefaultContainerProvisioner.cs b/src/core/Deal.Infrastructure/Tenancy/DefaultContainerProvisioner.cs index 53191ea..41007a9 100644 --- a/src/core/Deal.Infrastructure/Tenancy/DefaultContainerProvisioner.cs +++ b/src/core/Deal.Infrastructure/Tenancy/DefaultContainerProvisioner.cs @@ -4,8 +4,11 @@ using Deal.Infrastructure.Persistence.Entities; using Deal.Modules.Cards.Application.Abstractions; using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; using Microsoft.EntityFrameworkCore; namespace Deal.Infrastructure.Tenancy; diff --git a/src/core/Deal.Infrastructure/Tenancy/TenantProvisioningService.cs b/src/core/Deal.Infrastructure/Tenancy/TenantProvisioningService.cs index 017194f..dbb9c46 100644 --- a/src/core/Deal.Infrastructure/Tenancy/TenantProvisioningService.cs +++ b/src/core/Deal.Infrastructure/Tenancy/TenantProvisioningService.cs @@ -2,7 +2,11 @@ using System.Collections.Concurrent; using Deal.Infrastructure.Data; using Deal.Infrastructure.Migrations; using Deal.Infrastructure.Persistence; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants; using Microsoft.EntityFrameworkCore; using Npgsql; diff --git a/src/core/Deal.Infrastructure/Tenancy/TenantSchemaMigrationService.cs b/src/core/Deal.Infrastructure/Tenancy/TenantSchemaMigrationService.cs index 5ae887d..3887445 100644 --- a/src/core/Deal.Infrastructure/Tenancy/TenantSchemaMigrationService.cs +++ b/src/core/Deal.Infrastructure/Tenancy/TenantSchemaMigrationService.cs @@ -1,110 +1,113 @@ -using System.Collections.Concurrent; -using System.Diagnostics; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Tenants; -using Microsoft.Extensions.Logging; - -namespace Deal.Infrastructure.Tenancy; - -/// -/// Пакетная (maintenance) миграция схем ВСЕХ существующих тенантов реестра (этап 12, пакет C) с -/// ограниченным параллелизмом и логированием прогресса — для провижининга SaaS на сотни/тысячи схем. -/// -/// -/// Идемпотентность обеспечивает сам провижининг: CREATE SCHEMA IF NOT EXISTS + EF Core -/// MigrateAsync, который сверяется с таблицей истории __TenantMigrationsHistory схемы тенанта -/// и применяет ТОЛЬКО неприменённые миграции (повторный прогон ничего не меняет). Поэтому пакетный прогон -/// безопасен и при повторном запуске, и параллельно со стартовым bootstrap. Сбой одной схемы не прерывает -/// остальные: ошибка логируется, схема попадает в , а -/// операция завершается итоговой сводкой. Степень параллелизма клампится разумными границами, чтобы не -/// перегрузить Postgres при большом реестре. -/// -public sealed class TenantSchemaMigrationService( - ITenantRepository tenantRepository, - ITenantProvisioner tenantProvisioner, - ILogger logger) -{ - /// - /// Параллелизм пакетной миграции по умолчанию (схем одновременно). - /// - public const int DefaultMaxParallelism = 4; - - // Нижняя граница параллелизма (ноль/отрицательное значение недопустимо). - private const int MinMaxParallelism = 1; - - // Верхняя граница параллелизма: защита Postgres от лавины одновременных DDL-подключений. - private const int MaxMaxParallelism = 32; - - /// - /// Мигрирует схемы всех тенантов реестра с параллелизмом по умолчанию. - /// - /// Токен отмены. - /// Итоговая сводка пакетной миграции. - public Task MigrateAllAsync(CancellationToken ct) - => MigrateAllAsync(DefaultMaxParallelism, ct); - - /// - /// Мигрирует схемы всех тенантов реестра с заданным параллелизмом (значение клампится). - /// - /// Желаемое число схем, мигрируемых одновременно. - /// Токен отмены. - /// Итоговая сводка пакетной миграции. - public async Task MigrateAllAsync(int maxParallelism, CancellationToken ct) - { - int parallelism = Math.Clamp(maxParallelism, MinMaxParallelism, MaxMaxParallelism); - IReadOnlyList tenants = await tenantRepository.ListAsync(ct).ConfigureAwait(false); - var stopwatch = Stopwatch.StartNew(); - - if (tenants.Count == 0) - { - stopwatch.Stop(); - logger.LogInformation("Пакетная миграция схем: в реестре нет тенантов — мигрировать нечего"); - return new TenantMigrationSummary(0, 0, 0, stopwatch.ElapsedMilliseconds, []); - } - - var failedSchemas = new ConcurrentBag(); - int migrated = 0; - - await Parallel.ForEachAsync( - tenants, - new ParallelOptions { MaxDegreeOfParallelism = parallelism, CancellationToken = ct }, - async (tenant, token) => - { - var tenantId = new TenantId(tenant.Id.ToString("N")); - try - { - await tenantProvisioner.ProvisionAsync(tenantId, token).ConfigureAwait(false); - int done = Interlocked.Increment(ref migrated); - logger.LogInformation( - "Пакетная миграция схем: {Done}/{Total} — {Schema} готова", - done, - tenants.Count, - tenantId.SchemaName); - } - catch (Exception exception) when (exception is not OperationCanceledException) - { - failedSchemas.Add(tenantId.SchemaName); - logger.LogError( - exception, - "Пакетная миграция схем: {Schema} не мигрирована", - tenantId.SchemaName); - } - }).ConfigureAwait(false); - - stopwatch.Stop(); - var summary = new TenantMigrationSummary( - tenants.Count, - migrated, - failedSchemas.Count, - stopwatch.ElapsedMilliseconds, - failedSchemas.OrderBy(schema => schema, StringComparer.Ordinal).ToArray()); - logger.LogInformation( - "Пакетная миграция схем завершена: всего {Total}, успешно {Migrated}, сбоев {Failed}, {DurationMs} мс", - summary.Total, - summary.Migrated, - summary.Failed, - summary.DurationMs); - return summary; - } -} +using System.Collections.Concurrent; +using System.Diagnostics; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; +using Deal.SharedKernel.Tenants; +using Microsoft.Extensions.Logging; + +namespace Deal.Infrastructure.Tenancy; + +/// +/// Пакетная (maintenance) миграция схем ВСЕХ существующих тенантов реестра (этап 12, пакет C) с +/// ограниченным параллелизмом и логированием прогресса — для провижининга SaaS на сотни/тысячи схем. +/// +/// +/// Идемпотентность обеспечивает сам провижининг: CREATE SCHEMA IF NOT EXISTS + EF Core +/// MigrateAsync, который сверяется с таблицей истории __TenantMigrationsHistory схемы тенанта +/// и применяет ТОЛЬКО неприменённые миграции (повторный прогон ничего не меняет). Поэтому пакетный прогон +/// безопасен и при повторном запуске, и параллельно со стартовым bootstrap. Сбой одной схемы не прерывает +/// остальные: ошибка логируется, схема попадает в , а +/// операция завершается итоговой сводкой. Степень параллелизма клампится разумными границами, чтобы не +/// перегрузить Postgres при большом реестре. +/// +public sealed class TenantSchemaMigrationService( + ITenantRepository tenantRepository, + ITenantProvisioner tenantProvisioner, + ILogger logger) +{ + /// + /// Параллелизм пакетной миграции по умолчанию (схем одновременно). + /// + public const int DefaultMaxParallelism = 4; + + // Нижняя граница параллелизма (ноль/отрицательное значение недопустимо). + private const int MinMaxParallelism = 1; + + // Верхняя граница параллелизма: защита Postgres от лавины одновременных DDL-подключений. + private const int MaxMaxParallelism = 32; + + /// + /// Мигрирует схемы всех тенантов реестра с параллелизмом по умолчанию. + /// + /// Токен отмены. + /// Итоговая сводка пакетной миграции. + public Task MigrateAllAsync(CancellationToken ct) + => MigrateAllAsync(DefaultMaxParallelism, ct); + + /// + /// Мигрирует схемы всех тенантов реестра с заданным параллелизмом (значение клампится). + /// + /// Желаемое число схем, мигрируемых одновременно. + /// Токен отмены. + /// Итоговая сводка пакетной миграции. + public async Task MigrateAllAsync(int maxParallelism, CancellationToken ct) + { + int parallelism = Math.Clamp(maxParallelism, MinMaxParallelism, MaxMaxParallelism); + IReadOnlyList tenants = await tenantRepository.ListAsync(ct).ConfigureAwait(false); + var stopwatch = Stopwatch.StartNew(); + + if (tenants.Count == 0) + { + stopwatch.Stop(); + logger.LogInformation("Пакетная миграция схем: в реестре нет тенантов — мигрировать нечего"); + return new TenantMigrationSummary(0, 0, 0, stopwatch.ElapsedMilliseconds, []); + } + + var failedSchemas = new ConcurrentBag(); + int migrated = 0; + + await Parallel.ForEachAsync( + tenants, + new ParallelOptions { MaxDegreeOfParallelism = parallelism, CancellationToken = ct }, + async (tenant, token) => + { + var tenantId = new TenantId(tenant.Id.ToString("N")); + try + { + await tenantProvisioner.ProvisionAsync(tenantId, token).ConfigureAwait(false); + int done = Interlocked.Increment(ref migrated); + logger.LogInformation( + "Пакетная миграция схем: {Done}/{Total} — {Schema} готова", + done, + tenants.Count, + tenantId.SchemaName); + } + catch (Exception exception) when (exception is not OperationCanceledException) + { + failedSchemas.Add(tenantId.SchemaName); + logger.LogError( + exception, + "Пакетная миграция схем: {Schema} не мигрирована", + tenantId.SchemaName); + } + }).ConfigureAwait(false); + + stopwatch.Stop(); + var summary = new TenantMigrationSummary( + tenants.Count, + migrated, + failedSchemas.Count, + stopwatch.ElapsedMilliseconds, + failedSchemas.OrderBy(schema => schema, StringComparer.Ordinal).ToArray()); + logger.LogInformation( + "Пакетная миграция схем завершена: всего {Total}, успешно {Migrated}, сбоев {Failed}, {DurationMs} мс", + summary.Total, + summary.Migrated, + summary.Failed, + summary.DurationMs); + return summary; + } +} diff --git a/src/core/Deal.Modules.Discovery/Application/IDiscoveryPacer.cs b/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryPacer.cs similarity index 77% rename from src/core/Deal.Modules.Discovery/Application/IDiscoveryPacer.cs rename to src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryPacer.cs index 6f1cf52..144a1eb 100644 --- a/src/core/Deal.Modules.Discovery/Application/IDiscoveryPacer.cs +++ b/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryPacer.cs @@ -1,4 +1,10 @@ -namespace Deal.Modules.Discovery.Application; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; + +namespace Deal.Modules.Discovery.Application.Abstractions; /// /// Порт «пейсера» Discovery: паузы между сетевыми действиями (план Task 18, Ruling 10). diff --git a/src/core/Deal.Modules.Discovery/Application/IDiscoverySearchErrorCounter.cs b/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoverySearchErrorCounter.cs similarity index 85% rename from src/core/Deal.Modules.Discovery/Application/IDiscoverySearchErrorCounter.cs rename to src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoverySearchErrorCounter.cs index 893f9eb..261efbc 100644 --- a/src/core/Deal.Modules.Discovery/Application/IDiscoverySearchErrorCounter.cs +++ b/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoverySearchErrorCounter.cs @@ -1,4 +1,10 @@ -namespace Deal.Modules.Discovery.Application; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; + +namespace Deal.Modules.Discovery.Application.Abstractions; /// /// Счётчик ошибок поиска одного ключа по задачам (discovery_worker._search_errors L82, план Task 18). diff --git a/src/core/Deal.Modules.Discovery/Application/IDiscoveryStore.cs b/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryStore.cs similarity index 98% rename from src/core/Deal.Modules.Discovery/Application/IDiscoveryStore.cs rename to src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryStore.cs index 8c65d25..305136f 100644 --- a/src/core/Deal.Modules.Discovery/Application/IDiscoveryStore.cs +++ b/src/core/Deal.Modules.Discovery/Application/Abstractions/IDiscoveryStore.cs @@ -1,6 +1,10 @@ using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; -namespace Deal.Modules.Discovery.Application; +namespace Deal.Modules.Discovery.Application.Abstractions; /// /// Порт хранилища Discovery: таблицы DiscTasks/DiscCandidates/DiscBlacklist/DiscLog схемы тенанта (Ruling 9, Task 17). diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryValidationException.cs b/src/core/Deal.Modules.Discovery/Application/Exceptions/DiscoveryValidationException.cs similarity index 78% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryValidationException.cs rename to src/core/Deal.Modules.Discovery/Application/Exceptions/DiscoveryValidationException.cs index d42b19f..a9304d8 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryValidationException.cs +++ b/src/core/Deal.Modules.Discovery/Application/Exceptions/DiscoveryValidationException.cs @@ -1,4 +1,10 @@ -namespace Deal.Modules.Discovery.Application; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; + +namespace Deal.Modules.Discovery.Application.Exceptions; /// /// Доменная ошибка запроса Discovery — 400-семантика (аналог ValueError discovery.py). diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryTaskPatchExtensions.cs b/src/core/Deal.Modules.Discovery/Application/Extensions/DiscoveryTaskPatchExtensions.cs similarity index 73% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryTaskPatchExtensions.cs rename to src/core/Deal.Modules.Discovery/Application/Extensions/DiscoveryTaskPatchExtensions.cs index 429adad..1b46874 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryTaskPatchExtensions.cs +++ b/src/core/Deal.Modules.Discovery/Application/Extensions/DiscoveryTaskPatchExtensions.cs @@ -1,6 +1,10 @@ using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; -namespace Deal.Modules.Discovery.Application; +namespace Deal.Modules.Discovery.Application.Extensions; internal static class DiscoveryTaskPatchExtensions { diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateDto.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateDto.cs index 61e708d..568ea21 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateDto.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateDto.cs @@ -6,21 +6,21 @@ namespace Deal.Modules.Discovery.Application.Models; /// /// Поля 1:1 с _candidate_view discovery.py L140–158 и строкой disc_candidates (db.py L159–176). marks/topics — /// JSON-колонки, наружу всегда списки (marks — строки-метки, topics — элементы для -/// форумов). Статус — ; переводы в +/// форумов). Статус — ; переводы в /// joined/rejected — только через mark_joined/mark_rejected. /// /// Подписанный id источника (первичный ключ кандидата; как Dialogs.Id). /// Id задачи поиска, которой принадлежит кандидат. /// Отображаемое имя источника (пусто → DialogId). /// Username (handle) источника; пуст, если нет публичного username. -/// Тип источника: channel|group|forum (см. ). +/// Тип источника: channel|group|forum (см. ). /// Цвет источника из палитры DIALOG_HUES (hex «#rrggbb»; дефолт «#666»). /// Число участников (после discovery_info); null — неизвестно. /// Язык источника: true — русский, false — не русский, null — не определён. /// Метки оценки («закрытая группа (история скрыта)», «мало сообщений», …). /// Оценка тем форума (kind=forum; для каналов/групп пуст). /// Доля подходящих сообщений оценки (0..1); null — контент не оценён. -/// Статус кандидата (см. ). +/// Статус кандидата (см. ). /// Вступили автоматически (воркером); false — вручную (join из UI). /// Неудачные авто-вступления подряд (3 → кандидат удаляется, Task 18). /// Время добавления, epoch-ms. diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryCandidateKinds.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateKinds.cs similarity index 75% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryCandidateKinds.cs rename to src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateKinds.cs index 84f5176..43c6b60 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryCandidateKinds.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateKinds.cs @@ -1,4 +1,10 @@ -namespace Deal.Modules.Discovery.Application; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; + +namespace Deal.Modules.Discovery.Application.Models; /// /// Типы источников-кандидатов Discovery (колонка DiscCandidates.Kind; api-map §4.8, discovery_worker _kind_code). diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryCandidateStatuses.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateStatuses.cs similarity index 89% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryCandidateStatuses.cs rename to src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateStatuses.cs index 2f981c7..7f47c16 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryCandidateStatuses.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCandidateStatuses.cs @@ -1,4 +1,10 @@ -namespace Deal.Modules.Discovery.Application; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; + +namespace Deal.Modules.Discovery.Application.Models; /// /// Статусы кандидата Discovery (колонка DiscCandidates.Status; discovery.py L171, api-map §4.8). diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryCounterField.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCounterField.cs similarity index 81% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryCounterField.cs rename to src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCounterField.cs index 0696f4b..8563732 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryCounterField.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryCounterField.cs @@ -1,4 +1,10 @@ -namespace Deal.Modules.Discovery.Application; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; + +namespace Deal.Modules.Discovery.Application.Models; /// /// Счётчики прогресса задачи поиска (колонки DiscTasks Found/Evaluated/Joined/Rejected; discovery.py L359–368). diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryEvalSample.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryEvalSample.cs similarity index 69% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryEvalSample.cs rename to src/core/Deal.Modules.Discovery/Application/Models/DiscoveryEvalSample.cs index 591bc23..1733bc5 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryEvalSample.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryEvalSample.cs @@ -1,4 +1,10 @@ -namespace Deal.Modules.Discovery.Application; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; + +namespace Deal.Modules.Discovery.Application.Models; /// /// Агрегат оценки выборки сообщений (python evaluate_sample L197–226: fit_count/total/fit_ratio/per_message). diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryIdPrefixes.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryIdPrefixes.cs similarity index 88% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryIdPrefixes.cs rename to src/core/Deal.Modules.Discovery/Application/Models/DiscoveryIdPrefixes.cs index 3f19972..fc9ff1f 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryIdPrefixes.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryIdPrefixes.cs @@ -1,6 +1,11 @@ using System.Security.Cryptography; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; -namespace Deal.Modules.Discovery.Application; +namespace Deal.Modules.Discovery.Application.Models; /// /// Префиксы коротких id модуля Discovery и их генерация (прототип store.uid в discovery.py). diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryLogDto.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryLogDto.cs index 3713bb7..c2c4407 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryLogDto.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryLogDto.cs @@ -5,12 +5,12 @@ namespace Deal.Modules.Discovery.Application.Models; /// /// /// Поля 1:1 с _log_view discovery.py L170–177 и строкой disc_log (db.py L189–195). Событие — каталог -/// (search|skip|review|join_auto|join_manual| +/// (search|skip|review|join_auto|join_manual| /// reject|flood|error|done|…). Список — последние события задачи, новые сверху (ORDER BY created_at DESC). /// /// Короткий id записи (префикс dl_). /// Id задачи поиска. -/// Событие (см. ). +/// Событие (см. ). /// Текст/детали события (русская строка 1:1 с прототипом). /// Время события, epoch-ms. public sealed record DiscoveryLogDto( diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryLogEvents.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryLogEvents.cs similarity index 88% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryLogEvents.cs rename to src/core/Deal.Modules.Discovery/Application/Models/DiscoveryLogEvents.cs index b7db739..ddde80b 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryLogEvents.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryLogEvents.cs @@ -1,4 +1,10 @@ -namespace Deal.Modules.Discovery.Application; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; + +namespace Deal.Modules.Discovery.Application.Models; /// /// События лога задачи Discovery (колонка DiscLog.Event; discovery.py L187–188, api-map §3.8). diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryMessageFit.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryMessageFit.cs similarity index 60% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryMessageFit.cs rename to src/core/Deal.Modules.Discovery/Application/Models/DiscoveryMessageFit.cs index ab53619..31683d5 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryMessageFit.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryMessageFit.cs @@ -1,4 +1,10 @@ -namespace Deal.Modules.Discovery.Application; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; + +namespace Deal.Modules.Discovery.Application.Models; /// /// Вердикт фита одного сообщения (python evaluate_message L174–194). diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskDto.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskDto.cs index 50d0a69..4c411e1 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskDto.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskDto.cs @@ -5,7 +5,7 @@ namespace Deal.Modules.Discovery.Application.Models; /// /// /// Поля 1:1 с _task_view discovery.py L116–137 и строкой disc_tasks (db.py L136–156). JSON-поле keywords -/// наружу всегда список строк; статус — . +/// наружу всегда список строк; статус — . /// createdAt/updatedAt — epoch-ms (времена хранятся UTC, на границе переводятся в ms — конвенция проекта). /// /// Короткий id задачи (префикс dt_). @@ -18,7 +18,7 @@ namespace Deal.Modules.Discovery.Application.Models; /// Размер выборки сообщений при оценке (≥1; дефолт discEvalSample). /// План авто-вступлений задачи (1..discJoinLimit; занимает суточный бюджет). /// Авто-вступления воркером включены (иначе кандидатов вступает человек). -/// Статус задачи (см. ). +/// Статус задачи (см. ). /// Индекс текущего ключа поиска (прогресс прохода по keywords). /// Проход по всем ключам завершён (searchIdx ≥ keywords.Count). /// Найдено кандидатов (счётчик found). diff --git a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskRow.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskRow.cs index bc32325..20f5c61 100644 --- a/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskRow.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskRow.cs @@ -5,7 +5,7 @@ namespace Deal.Modules.Discovery.Application.Models; /// /// /// Write-модель: содержит полное состояние новой задачи, включая готовый id (dt_..., генерирует модуль — -/// ). Служебные значения, вычисленные до +/// ). Служебные значения, вычисленные до /// записи: Status=draft, SearchIdx=0, SearchDone=false, счётчики Found/Evaluated/Joined/Rejected=0 — /// как в INSERT прототипа discovery.py L260–280. CreatedAt/UpdatedAt проставляет хранилище (UTC-now); /// keywords адаптер сериализует в JSON при записи. diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryTaskStatuses.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskStatuses.cs similarity index 88% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryTaskStatuses.cs rename to src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskStatuses.cs index 2d7a44a..961976e 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryTaskStatuses.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTaskStatuses.cs @@ -1,4 +1,10 @@ -namespace Deal.Modules.Discovery.Application; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; + +namespace Deal.Modules.Discovery.Application.Models; /// /// Статусы задачи поиска Discovery (колонка DiscTasks.Status; discovery.py L147, api-map §4.8). diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryTopicGroup.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTopicGroup.cs similarity index 68% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryTopicGroup.cs rename to src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTopicGroup.cs index fc5bfe2..7a0a6db 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryTopicGroup.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryTopicGroup.cs @@ -1,6 +1,11 @@ using Deal.Contracts.Integrations.Models; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; -namespace Deal.Modules.Discovery.Application; +namespace Deal.Modules.Discovery.Application.Models; /// /// Группа сообщений форума по теме (python group_by_topic L96–117). diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryWorkerOutcome.cs b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryWorkerOutcome.cs similarity index 68% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryWorkerOutcome.cs rename to src/core/Deal.Modules.Discovery/Application/Models/DiscoveryWorkerOutcome.cs index c793b81..c6ded70 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryWorkerOutcome.cs +++ b/src/core/Deal.Modules.Discovery/Application/Models/DiscoveryWorkerOutcome.cs @@ -1,4 +1,10 @@ -namespace Deal.Modules.Discovery.Application; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; + +namespace Deal.Modules.Discovery.Application.Models; /// /// Результат одного тика discovery-воркера (python discovery_worker L5–6: {action, taskId}). diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryModuleRegistrar.cs b/src/core/Deal.Modules.Discovery/Application/Registrars/DiscoveryModuleRegistrar.cs similarity index 84% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryModuleRegistrar.cs rename to src/core/Deal.Modules.Discovery/Application/Registrars/DiscoveryModuleRegistrar.cs index 5d1e67c..5f471d1 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryModuleRegistrar.cs +++ b/src/core/Deal.Modules.Discovery/Application/Registrars/DiscoveryModuleRegistrar.cs @@ -1,7 +1,15 @@ -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; using Microsoft.Extensions.DependencyInjection; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Services; -namespace Deal.Modules.Discovery.Application; +namespace Deal.Modules.Discovery.Application.Registrars; /// /// DI-регистрация модуля Discovery. Паттерн «port & adapter» (Ruling 9, план Task 17). diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryBanGuard.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBanGuard.cs similarity index 93% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryBanGuard.cs rename to src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBanGuard.cs index c43ade1..632cef6 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryBanGuard.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBanGuard.cs @@ -1,7 +1,15 @@ using System.Text.Json; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; -namespace Deal.Modules.Discovery.Application; +namespace Deal.Modules.Discovery.Application.Services; /// /// Бан-гард авто-вступлений Discovery: суточный лимит, flood-день, стоп-кран (1:1 ban_guard.py L1–81, план Task 18, Ruling 10). diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryBlacklistService.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBlacklistService.cs similarity index 92% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryBlacklistService.cs rename to src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBlacklistService.cs index 1aac96b..f21c877 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryBlacklistService.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryBlacklistService.cs @@ -1,6 +1,10 @@ using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Registrars; -namespace Deal.Modules.Discovery.Application; +namespace Deal.Modules.Discovery.Application.Services; /// /// Сервис чёрного списка Discovery — добавление/снятие/список (1:1 add_blacklist/remove_blacklist/list_blacklist L568–589). diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryCandidatesService.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryCandidatesService.cs similarity index 98% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryCandidatesService.cs rename to src/core/Deal.Modules.Discovery/Application/Services/DiscoveryCandidatesService.cs index 70203a6..3f32f2a 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryCandidatesService.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryCandidatesService.cs @@ -1,7 +1,11 @@ using Deal.Contracts.Integrations; using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Registrars; -namespace Deal.Modules.Discovery.Application; +namespace Deal.Modules.Discovery.Application.Services; /// /// Сервис кандидатов Discovery — add с исключениями, set_candidate, review-перевод, mark_joined/rejected, delete (1:1 L385–563). diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryEvaluator.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryEvaluator.cs similarity index 95% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryEvaluator.cs rename to src/core/Deal.Modules.Discovery/Application/Services/DiscoveryEvaluator.cs index d7d4418..1638bdc 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryEvaluator.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryEvaluator.cs @@ -2,9 +2,16 @@ using System.Text; using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Registrars; -namespace Deal.Modules.Discovery.Application; +namespace Deal.Modules.Discovery.Application.Services; /// /// Оценка содержания кандидата: фит сообщений под задачу поиска (1:1 discovery_eval.py целиком, план Task 18). diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryLangDetector.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryLangDetector.cs similarity index 88% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryLangDetector.cs rename to src/core/Deal.Modules.Discovery/Application/Services/DiscoveryLangDetector.cs index f8c021d..6fde411 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryLangDetector.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryLangDetector.cs @@ -1,4 +1,10 @@ -namespace Deal.Modules.Discovery.Application; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; + +namespace Deal.Modules.Discovery.Application.Services; /// /// Детектор «русскости» выборки сообщений кандидата (1:1 discovery_eval.detect_lang_ru L62–83, план Task 18). diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryLogService.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryLogService.cs similarity index 92% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryLogService.cs rename to src/core/Deal.Modules.Discovery/Application/Services/DiscoveryLogService.cs index 145aca1..0d6a310 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryLogService.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryLogService.cs @@ -1,6 +1,10 @@ using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Registrars; -namespace Deal.Modules.Discovery.Application; +namespace Deal.Modules.Discovery.Application.Services; /// /// Сервис лога задач Discovery — запись событий и чтение истории (1:1 add_log/task_log discovery.py L594–608). diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryPacer.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPacer.cs similarity index 74% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryPacer.cs rename to src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPacer.cs index bc59868..9fe124d 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryPacer.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPacer.cs @@ -1,6 +1,14 @@ -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; -namespace Deal.Modules.Discovery.Application; +namespace Deal.Modules.Discovery.Application.Services; /// /// Продовая реализация : случайная пауза из настроек тенанта (ban_guard L44–56). diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryPlanGuard.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPlanGuard.cs similarity index 89% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryPlanGuard.cs rename to src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPlanGuard.cs index 07124e0..3dcbae5 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryPlanGuard.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryPlanGuard.cs @@ -1,6 +1,14 @@ -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; -namespace Deal.Modules.Discovery.Application; +namespace Deal.Modules.Discovery.Application.Services; /// /// План-бюджет авто-вступлений Discovery: лимит и правило суммы (python L80–111, Ruling 9). diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoverySearchErrorCounter.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoverySearchErrorCounter.cs similarity index 91% rename from src/core/Deal.Modules.Discovery/Application/DiscoverySearchErrorCounter.cs rename to src/core/Deal.Modules.Discovery/Application/Services/DiscoverySearchErrorCounter.cs index 8766248..4dd4228 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoverySearchErrorCounter.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoverySearchErrorCounter.cs @@ -1,6 +1,11 @@ using System.Collections.Concurrent; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; -namespace Deal.Modules.Discovery.Application; +namespace Deal.Modules.Discovery.Application.Services; /// /// Потокобезопасная реализация (ConcurrentDictionary + TTL). diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryTasksService.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryTasksService.cs similarity index 96% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryTasksService.cs rename to src/core/Deal.Modules.Discovery/Application/Services/DiscoveryTasksService.cs index 5aa2ce4..b61b4bc 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryTasksService.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryTasksService.cs @@ -1,7 +1,14 @@ using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Registrars; -namespace Deal.Modules.Discovery.Application; +namespace Deal.Modules.Discovery.Application.Services; /// /// Сервис задач поиска Discovery — create/patch/delete/start/pause/advance/bump (план Task 17, 1:1 discovery.py L234–381). diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryWorkerService.Constants.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Constants.cs similarity index 93% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryWorkerService.Constants.cs rename to src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Constants.cs index 4c3d7e9..962ac34 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryWorkerService.Constants.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Constants.cs @@ -1,4 +1,10 @@ -namespace Deal.Modules.Discovery.Application; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; + +namespace Deal.Modules.Discovery.Application.Services; // Часть DiscoveryWorkerService: константы — действия тика, пороги/размеры воркера и метки кандидатов // (1:1 python discovery_worker L5–6/L74–89). diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryWorkerService.Evaluate.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Evaluate.cs similarity index 97% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryWorkerService.Evaluate.cs rename to src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Evaluate.cs index 2543fb5..46020cf 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryWorkerService.Evaluate.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Evaluate.cs @@ -1,8 +1,12 @@ using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Registrars; -namespace Deal.Modules.Discovery.Application; +namespace Deal.Modules.Discovery.Application.Services; // Часть DiscoveryWorkerService: шаг оценки кандидата status='new' — инфо источника, выборка, язык, объём // и оценка содержания (python _eval_step L228–315/_evaluate_content L318–356) + перевод в review. diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryWorkerService.Helpers.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Helpers.cs similarity index 91% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryWorkerService.Helpers.cs rename to src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Helpers.cs index 2012e49..54f414e 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryWorkerService.Helpers.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Helpers.cs @@ -1,8 +1,12 @@ using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Registrars; -namespace Deal.Modules.Discovery.Application; +namespace Deal.Modules.Discovery.Application.Services; // Часть DiscoveryWorkerService: общие хелперы — распознавание FloodWait (IsFlood), нормализация kind // источника (KindCode) и тексты сообщений для оценки (GroupTexts). diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryWorkerService.Join.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Join.cs similarity index 96% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryWorkerService.Join.cs rename to src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Join.cs index 0d4bcd1..2ec3970 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryWorkerService.Join.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Join.cs @@ -1,8 +1,12 @@ using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Registrars; -namespace Deal.Modules.Discovery.Application; +namespace Deal.Modules.Discovery.Application.Services; // Часть DiscoveryWorkerService: шаг авто-вступления кандидата status='review' — повторные проверки, // пауза, join, обработка FloodWait/неудач (python _join_step L359–439). diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryWorkerService.Search.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Search.cs similarity index 95% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryWorkerService.Search.cs rename to src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Search.cs index d37fb0b..eff9ef3 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryWorkerService.Search.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.Search.cs @@ -1,8 +1,12 @@ using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Registrars; -namespace Deal.Modules.Discovery.Application; +namespace Deal.Modules.Discovery.Application.Services; // Часть DiscoveryWorkerService: шаг поиска — один ключ keywords[SearchIdx] → кандидаты + advance_search // (python _search_step L177–225) и лог завершения прохода. diff --git a/src/core/Deal.Modules.Discovery/Application/DiscoveryWorkerService.cs b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.cs similarity index 97% rename from src/core/Deal.Modules.Discovery/Application/DiscoveryWorkerService.cs rename to src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.cs index 17ab165..4ce01df 100644 --- a/src/core/Deal.Modules.Discovery/Application/DiscoveryWorkerService.cs +++ b/src/core/Deal.Modules.Discovery/Application/Services/DiscoveryWorkerService.cs @@ -1,8 +1,12 @@ using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Registrars; -namespace Deal.Modules.Discovery.Application; +namespace Deal.Modules.Discovery.Application.Services; /// /// Воркер Discovery: поиск → оценка → авто-вступление, один шаг за тик (1:1 discovery_worker.py целиком, план Task 18, Ruling 10). diff --git a/src/core/Deal.Modules.Kanban/Application/ICardStore.cs b/src/core/Deal.Modules.Kanban/Application/Abstractions/ICardStore.cs similarity index 99% rename from src/core/Deal.Modules.Kanban/Application/ICardStore.cs rename to src/core/Deal.Modules.Kanban/Application/Abstractions/ICardStore.cs index 2c02774..07616d9 100644 --- a/src/core/Deal.Modules.Kanban/Application/ICardStore.cs +++ b/src/core/Deal.Modules.Kanban/Application/Abstractions/ICardStore.cs @@ -1,6 +1,9 @@ using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; -namespace Deal.Modules.Kanban.Application; +namespace Deal.Modules.Kanban.Application.Abstractions; /// /// Единый порт хранилища карточек и контейнеров (таблицы Cards/Containers/LeadComments/CardMoves тенанта; diff --git a/src/core/Deal.Modules.Kanban/Application/IMlLearningStore.cs b/src/core/Deal.Modules.Kanban/Application/Abstractions/IMlLearningStore.cs similarity index 95% rename from src/core/Deal.Modules.Kanban/Application/IMlLearningStore.cs rename to src/core/Deal.Modules.Kanban/Application/Abstractions/IMlLearningStore.cs index 9450ff9..57e8b86 100644 --- a/src/core/Deal.Modules.Kanban/Application/IMlLearningStore.cs +++ b/src/core/Deal.Modules.Kanban/Application/Abstractions/IMlLearningStore.cs @@ -1,6 +1,9 @@ using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; -namespace Deal.Modules.Kanban.Application; +namespace Deal.Modules.Kanban.Application.Abstractions; /// /// Порт хранилища обучения ML: очередь MlOutbox + счётчик журнала CardMoves (Ruling 4, Task 5). diff --git a/src/core/Deal.Modules.Kanban/Application/ColumnRules/BudgetInRange.cs b/src/core/Deal.Modules.Kanban/Application/ColumnRules/BudgetInRange.cs index 12d9632..e41e71f 100644 --- a/src/core/Deal.Modules.Kanban/Application/ColumnRules/BudgetInRange.cs +++ b/src/core/Deal.Modules.Kanban/Application/ColumnRules/BudgetInRange.cs @@ -1,5 +1,8 @@ using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Modules.Kanban.Application.ColumnRules; diff --git a/src/core/Deal.Modules.Kanban/Application/CharExtensions.cs b/src/core/Deal.Modules.Kanban/Application/Extensions/CharExtensions.cs similarity index 71% rename from src/core/Deal.Modules.Kanban/Application/CharExtensions.cs rename to src/core/Deal.Modules.Kanban/Application/Extensions/CharExtensions.cs index 67c581c..ebd4952 100644 --- a/src/core/Deal.Modules.Kanban/Application/CharExtensions.cs +++ b/src/core/Deal.Modules.Kanban/Application/Extensions/CharExtensions.cs @@ -1,4 +1,9 @@ -namespace Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; + +namespace Deal.Modules.Kanban.Application.Extensions; /// /// Расширения символов для нормализации бюджетной валюты. diff --git a/src/core/Deal.Modules.Kanban/Application/CardFileKind.cs b/src/core/Deal.Modules.Kanban/Application/Models/CardFileKind.cs similarity index 77% rename from src/core/Deal.Modules.Kanban/Application/CardFileKind.cs rename to src/core/Deal.Modules.Kanban/Application/Models/CardFileKind.cs index 3b5ebab..06bb9c5 100644 --- a/src/core/Deal.Modules.Kanban/Application/CardFileKind.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/CardFileKind.cs @@ -1,4 +1,9 @@ -namespace Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; + +namespace Deal.Modules.Kanban.Application.Models; /// /// Результат — kind/label вложения (1:1 files.py detect L31–45). diff --git a/src/core/Deal.Modules.Kanban/Application/ContainerKinds.cs b/src/core/Deal.Modules.Kanban/Application/Models/ContainerKinds.cs similarity index 84% rename from src/core/Deal.Modules.Kanban/Application/ContainerKinds.cs rename to src/core/Deal.Modules.Kanban/Application/Models/ContainerKinds.cs index ee31360..ebd68ce 100644 --- a/src/core/Deal.Modules.Kanban/Application/ContainerKinds.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/ContainerKinds.cs @@ -1,4 +1,9 @@ -namespace Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; + +namespace Deal.Modules.Kanban.Application.Models; /// /// Реестр видов контейнеров (этап 9, T4): роль колонки в пространстве. diff --git a/src/core/Deal.Modules.Kanban/Application/ContainerSpaces.cs b/src/core/Deal.Modules.Kanban/Application/Models/ContainerSpaces.cs similarity index 81% rename from src/core/Deal.Modules.Kanban/Application/ContainerSpaces.cs rename to src/core/Deal.Modules.Kanban/Application/Models/ContainerSpaces.cs index f3a9ec4..de06ba9 100644 --- a/src/core/Deal.Modules.Kanban/Application/ContainerSpaces.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/ContainerSpaces.cs @@ -1,4 +1,9 @@ -namespace Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; + +namespace Deal.Modules.Kanban.Application.Models; /// /// Реестр пространств контейнеров (этап 9, T4): где показывается карточка. diff --git a/src/core/Deal.Modules.Kanban/Application/KanbanColumns.cs b/src/core/Deal.Modules.Kanban/Application/Models/KanbanColumns.cs similarity index 84% rename from src/core/Deal.Modules.Kanban/Application/KanbanColumns.cs rename to src/core/Deal.Modules.Kanban/Application/Models/KanbanColumns.cs index 2fc6519..d7e2e68 100644 --- a/src/core/Deal.Modules.Kanban/Application/KanbanColumns.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/KanbanColumns.cs @@ -1,4 +1,9 @@ -namespace Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; + +namespace Deal.Modules.Kanban.Application.Models; /// /// Реестр служебных (не-доски) колонок канбана (Ruling 1, constants.py SERVICE_COLS). diff --git a/src/core/Deal.Modules.Kanban/Application/KanbanIdPrefixes.cs b/src/core/Deal.Modules.Kanban/Application/Models/KanbanIdPrefixes.cs similarity index 91% rename from src/core/Deal.Modules.Kanban/Application/KanbanIdPrefixes.cs rename to src/core/Deal.Modules.Kanban/Application/Models/KanbanIdPrefixes.cs index d297632..80caca3 100644 --- a/src/core/Deal.Modules.Kanban/Application/KanbanIdPrefixes.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/KanbanIdPrefixes.cs @@ -1,8 +1,12 @@ using Deal.Modules.Cards.Application.Abstractions; using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; -namespace Deal.Modules.Kanban.Application; +namespace Deal.Modules.Kanban.Application.Models; /// /// Реестр префиксов коротких id модуля Kanban (Ruling 12, прототип store.uid). diff --git a/src/core/Deal.Modules.Kanban/Application/PrefixId.cs b/src/core/Deal.Modules.Kanban/Application/Models/PrefixId.cs similarity index 86% rename from src/core/Deal.Modules.Kanban/Application/PrefixId.cs rename to src/core/Deal.Modules.Kanban/Application/Models/PrefixId.cs index 6fd4b67..9e4c124 100644 --- a/src/core/Deal.Modules.Kanban/Application/PrefixId.cs +++ b/src/core/Deal.Modules.Kanban/Application/Models/PrefixId.cs @@ -1,6 +1,10 @@ using System.Security.Cryptography; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; -namespace Deal.Modules.Kanban.Application; +namespace Deal.Modules.Kanban.Application.Models; /// /// Генератор коротких префиксных id модуля Kanban (Ruling 12, прототип store.uid = prefix + uuid4().hex[:12]). diff --git a/src/core/Deal.Modules.Kanban/Application/KanbanModuleRegistrar.cs b/src/core/Deal.Modules.Kanban/Application/Registrars/KanbanModuleRegistrar.cs similarity index 84% rename from src/core/Deal.Modules.Kanban/Application/KanbanModuleRegistrar.cs rename to src/core/Deal.Modules.Kanban/Application/Registrars/KanbanModuleRegistrar.cs index fefc6a3..ffe9a9f 100644 --- a/src/core/Deal.Modules.Kanban/Application/KanbanModuleRegistrar.cs +++ b/src/core/Deal.Modules.Kanban/Application/Registrars/KanbanModuleRegistrar.cs @@ -1,7 +1,14 @@ -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; using Microsoft.Extensions.DependencyInjection; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Services; -namespace Deal.Modules.Kanban.Application; +namespace Deal.Modules.Kanban.Application.Registrars; /// /// DI-регистрация модуля Kanban. Паттерн «port & adapter» (Ruling 12). diff --git a/src/core/Deal.Modules.Kanban/Application/BudgetNormalizer.cs b/src/core/Deal.Modules.Kanban/Application/Services/BudgetNormalizer.cs similarity index 95% rename from src/core/Deal.Modules.Kanban/Application/BudgetNormalizer.cs rename to src/core/Deal.Modules.Kanban/Application/Services/BudgetNormalizer.cs index 38f4335..421978d 100644 --- a/src/core/Deal.Modules.Kanban/Application/BudgetNormalizer.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/BudgetNormalizer.cs @@ -1,7 +1,13 @@ using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Registrars; -namespace Deal.Modules.Kanban.Application; +namespace Deal.Modules.Kanban.Application.Services; /// /// Нормализация бюджета «при поступлении»: приведение к форме хранения и пересчёт в целевую валюту diff --git a/src/core/Deal.Modules.Kanban/Application/CardsService.Files.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Files.cs similarity index 96% rename from src/core/Deal.Modules.Kanban/Application/CardsService.Files.cs rename to src/core/Deal.Modules.Kanban/Application/Services/CardsService.Files.cs index 1da8173..3ac8e40 100644 --- a/src/core/Deal.Modules.Kanban/Application/CardsService.Files.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Files.cs @@ -1,7 +1,10 @@ using Deal.Contracts.Integrations; using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Registrars; -namespace Deal.Modules.Kanban.Application; +namespace Deal.Modules.Kanban.Application.Services; /// /// Файлы карточки — partial-часть (этап 9: тот же домен карточки): diff --git a/src/core/Deal.Modules.Kanban/Application/CardsService.Helpers.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Helpers.cs similarity index 92% rename from src/core/Deal.Modules.Kanban/Application/CardsService.Helpers.cs rename to src/core/Deal.Modules.Kanban/Application/Services/CardsService.Helpers.cs index a332b01..0116089 100644 --- a/src/core/Deal.Modules.Kanban/Application/CardsService.Helpers.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Helpers.cs @@ -5,12 +5,18 @@ using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Models; using Deal.Modules.Kanban.Application.ColumnRules; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; // Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён — внутри пространства имён // Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство (CS0234), нужен явный алиас. using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Registrars; -namespace Deal.Modules.Kanban.Application; +namespace Deal.Modules.Kanban.Application.Services; /// /// Приватные помощники — partial-часть (C32: выделено из общего файла, diff --git a/src/core/Deal.Modules.Kanban/Application/CardsService.Operations.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Operations.cs similarity index 96% rename from src/core/Deal.Modules.Kanban/Application/CardsService.Operations.cs rename to src/core/Deal.Modules.Kanban/Application/Services/CardsService.Operations.cs index a98b531..bc0d547 100644 --- a/src/core/Deal.Modules.Kanban/Application/CardsService.Operations.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Operations.cs @@ -5,9 +5,15 @@ using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Models; using Deal.Modules.Kanban.Application.ColumnRules; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Registrars; -namespace Deal.Modules.Kanban.Application; +namespace Deal.Modules.Kanban.Application.Services; /// /// Публичные операции карточек — partial-часть (C32: выделено из общего diff --git a/src/core/Deal.Modules.Kanban/Application/CardsService.Reminders.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Reminders.cs similarity index 93% rename from src/core/Deal.Modules.Kanban/Application/CardsService.Reminders.cs rename to src/core/Deal.Modules.Kanban/Application/Services/CardsService.Reminders.cs index 4423743..e1c18e4 100644 --- a/src/core/Deal.Modules.Kanban/Application/CardsService.Reminders.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Reminders.cs @@ -1,7 +1,13 @@ using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Registrars; -namespace Deal.Modules.Kanban.Application; +namespace Deal.Modules.Kanban.Application.Services; /// /// Напоминания «Отложено» — partial-часть (этап 9: тот же домен карточки): diff --git a/src/core/Deal.Modules.Kanban/Application/CardsService.Selected.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Selected.cs similarity index 97% rename from src/core/Deal.Modules.Kanban/Application/CardsService.Selected.cs rename to src/core/Deal.Modules.Kanban/Application/Services/CardsService.Selected.cs index 384ae81..9cf43b6 100644 --- a/src/core/Deal.Modules.Kanban/Application/CardsService.Selected.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.Selected.cs @@ -3,8 +3,11 @@ using Deal.Modules.Cards.Application.Abstractions; using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Models; using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Registrars; -namespace Deal.Modules.Kanban.Application; +namespace Deal.Modules.Kanban.Application.Services; /// /// Операции пространства «Выбранные» — partial-часть (этап 9: тот же домен diff --git a/src/core/Deal.Modules.Kanban/Application/CardsService.cs b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.cs similarity index 95% rename from src/core/Deal.Modules.Kanban/Application/CardsService.cs rename to src/core/Deal.Modules.Kanban/Application/Services/CardsService.cs index 1bf2e4d..da7d047 100644 --- a/src/core/Deal.Modules.Kanban/Application/CardsService.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/CardsService.cs @@ -2,12 +2,18 @@ using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; using Deal.Modules.Kanban.Application.ColumnRules; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; // Алиас: статический класс ColumnRules лежит в одноимённом пространстве имён — внутри пространства имён // Deal.Modules.Kanban.Application имя ColumnRules резолвится в пространство (CS0234), нужен явный алиас. using KanbanColumnRules = Deal.Modules.Kanban.Application.ColumnRules.ColumnRules; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Registrars; -namespace Deal.Modules.Kanban.Application; +namespace Deal.Modules.Kanban.Application.Services; /// /// Сервис карточек — единый домен карточки (дашборд и «Выбранные»): leads.py L151–279 + L509–551 diff --git a/src/core/Deal.Modules.Kanban/Application/ContainersService.cs b/src/core/Deal.Modules.Kanban/Application/Services/ContainersService.cs similarity index 97% rename from src/core/Deal.Modules.Kanban/Application/ContainersService.cs rename to src/core/Deal.Modules.Kanban/Application/Services/ContainersService.cs index 903adcc..836c5a0 100644 --- a/src/core/Deal.Modules.Kanban/Application/ContainersService.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/ContainersService.cs @@ -1,10 +1,15 @@ using System.Text.Json; using System.Text.Json.Serialization; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Registrars; -namespace Deal.Modules.Kanban.Application; +namespace Deal.Modules.Kanban.Application.Services; /// /// Сервис контейнеров (колонок/стадий/зон) и состояния колонок (colState) — этап 9, T4. diff --git a/src/core/Deal.Modules.Kanban/Application/ConversionRecomputer.cs b/src/core/Deal.Modules.Kanban/Application/Services/ConversionRecomputer.cs similarity index 94% rename from src/core/Deal.Modules.Kanban/Application/ConversionRecomputer.cs rename to src/core/Deal.Modules.Kanban/Application/Services/ConversionRecomputer.cs index 951aee4..cee9400 100644 --- a/src/core/Deal.Modules.Kanban/Application/ConversionRecomputer.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/ConversionRecomputer.cs @@ -1,8 +1,13 @@ using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Registrars; -namespace Deal.Modules.Kanban.Application; +namespace Deal.Modules.Kanban.Application.Services; /// /// Пересчёт конверсий бюджетов карточек при смене курсов/целевой валюты (Ruling 7, план Task 12). diff --git a/src/core/Deal.Modules.Kanban/Application/FileKindDetector.cs b/src/core/Deal.Modules.Kanban/Application/Services/FileKindDetector.cs similarity index 96% rename from src/core/Deal.Modules.Kanban/Application/FileKindDetector.cs rename to src/core/Deal.Modules.Kanban/Application/Services/FileKindDetector.cs index 81f2b41..f71e0fe 100644 --- a/src/core/Deal.Modules.Kanban/Application/FileKindDetector.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/FileKindDetector.cs @@ -1,4 +1,9 @@ -namespace Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; + +namespace Deal.Modules.Kanban.Application.Services; /// /// Чистый детектор типа вложения карточки (Ruling 4; 1:1 files.py detect L31–45). diff --git a/src/core/Deal.Modules.Kanban/Application/StorageTickService.cs b/src/core/Deal.Modules.Kanban/Application/Services/StorageTickService.cs similarity index 93% rename from src/core/Deal.Modules.Kanban/Application/StorageTickService.cs rename to src/core/Deal.Modules.Kanban/Application/Services/StorageTickService.cs index c583e73..3001950 100644 --- a/src/core/Deal.Modules.Kanban/Application/StorageTickService.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/StorageTickService.cs @@ -1,7 +1,13 @@ using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Registrars; -namespace Deal.Modules.Kanban.Application; +namespace Deal.Modules.Kanban.Application.Services; /// /// Сервис правил хранения: автоархив и очистка архива/корзины по срокам — Ruling 8, план Task 10. diff --git a/src/core/Deal.Modules.Kanban/Application/SuggestHeuristics.cs b/src/core/Deal.Modules.Kanban/Application/Services/SuggestHeuristics.cs similarity index 97% rename from src/core/Deal.Modules.Kanban/Application/SuggestHeuristics.cs rename to src/core/Deal.Modules.Kanban/Application/Services/SuggestHeuristics.cs index da62e39..70ce832 100644 --- a/src/core/Deal.Modules.Kanban/Application/SuggestHeuristics.cs +++ b/src/core/Deal.Modules.Kanban/Application/Services/SuggestHeuristics.cs @@ -3,8 +3,11 @@ using Deal.Modules.Cards.Application.Abstractions; using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Models; using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Registrars; -namespace Deal.Modules.Kanban.Application; +namespace Deal.Modules.Kanban.Application.Services; /// /// Чистое ядро ИИ-предложений колонок/ключей — план Task 14 L467–471, Ruling 3. diff --git a/src/core/Deal.Modules.Pipeline/Application/IPipelineStore.cs b/src/core/Deal.Modules.Pipeline/Application/Abstractions/IPipelineStore.cs similarity index 98% rename from src/core/Deal.Modules.Pipeline/Application/IPipelineStore.cs rename to src/core/Deal.Modules.Pipeline/Application/Abstractions/IPipelineStore.cs index 6a2f325..9af99a6 100644 --- a/src/core/Deal.Modules.Pipeline/Application/IPipelineStore.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Abstractions/IPipelineStore.cs @@ -1,6 +1,8 @@ using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; -namespace Deal.Modules.Pipeline.Application; +namespace Deal.Modules.Pipeline.Application.Abstractions; /// /// Порт хранилища пайплайна (таблицы QueueItems/RejectedItems/DedupEntries тенанта), Ruling 1. diff --git a/src/core/Deal.Modules.Pipeline/Application/PipelineIdPrefixes.cs b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineIdPrefixes.cs similarity index 87% rename from src/core/Deal.Modules.Pipeline/Application/PipelineIdPrefixes.cs rename to src/core/Deal.Modules.Pipeline/Application/Models/PipelineIdPrefixes.cs index 85b4562..ee58512 100644 --- a/src/core/Deal.Modules.Pipeline/Application/PipelineIdPrefixes.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineIdPrefixes.cs @@ -1,4 +1,8 @@ -namespace Deal.Modules.Pipeline.Application; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; + +namespace Deal.Modules.Pipeline.Application.Models; /// /// Реестр префиксов коротких id модуля Pipeline (Ruling 10; прототип store.uid). diff --git a/src/core/Deal.Modules.Pipeline/Application/PipelineQueueStatuses.cs b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineQueueStatuses.cs similarity index 84% rename from src/core/Deal.Modules.Pipeline/Application/PipelineQueueStatuses.cs rename to src/core/Deal.Modules.Pipeline/Application/Models/PipelineQueueStatuses.cs index 7309053..8deaeaa 100644 --- a/src/core/Deal.Modules.Pipeline/Application/PipelineQueueStatuses.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineQueueStatuses.cs @@ -1,4 +1,8 @@ -namespace Deal.Modules.Pipeline.Application; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; + +namespace Deal.Modules.Pipeline.Application.Models; /// /// Статусы строк очереди — колонка QueueItems.Status (pipeline.py ST_NEW/ST_AI L42–44). diff --git a/src/core/Deal.Modules.Pipeline/Application/PipelineRejectConstants.cs b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineRejectConstants.cs similarity index 95% rename from src/core/Deal.Modules.Pipeline/Application/PipelineRejectConstants.cs rename to src/core/Deal.Modules.Pipeline/Application/Models/PipelineRejectConstants.cs index 8fd67c7..c9195a5 100644 --- a/src/core/Deal.Modules.Pipeline/Application/PipelineRejectConstants.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/PipelineRejectConstants.cs @@ -1,4 +1,8 @@ -namespace Deal.Modules.Pipeline.Application; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; + +namespace Deal.Modules.Pipeline.Application.Models; /// /// Константы отсева: подписи этапов/источников и срок хранения (processing.py L26–46, Rulings 1/9). diff --git a/src/core/Deal.Modules.Pipeline/Application/Models/RejectedItemDto.cs b/src/core/Deal.Modules.Pipeline/Application/Models/RejectedItemDto.cs index 38a3ac8..7c17b6c 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Models/RejectedItemDto.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Models/RejectedItemDto.cs @@ -9,7 +9,7 @@ namespace Deal.Modules.Pipeline.Application.Models; /// Поля 1:1 с §4.5: stage/stageLabel/reason/kw/source/sourceLabel и т.д. stage — этап отсева /// (length|stop|resume|type|budget|stale|spam_ml|spam_ai|filter_ai|dup), source — «чьё» решение /// (stop|ml|ai|stale|dup). Подписи stageLabel/sourceLabel считает слой маппинга через -/// (prototype stage_label/source_label L40–45). returned/returnedAt/ +/// (prototype stage_label/source_label L40–45). returned/returnedAt/ /// returnReason — аудит возврата из отсева (processing.return_to_queue L128–193; «Возврат» в UI неактивен при /// source=dup или returned). Времена наружу epoch-ms (адаптер мапит DateTimeOffset-строку, Ruling 1). /// @@ -36,7 +36,7 @@ public sealed record RejectedItemDto public string Text { get; init; } = string.Empty; /// - /// Этап отсева (см. словарь подписей ). + /// Этап отсева (см. словарь подписей ). /// public string Stage { get; init; } = string.Empty; @@ -61,7 +61,7 @@ public sealed record RejectedItemDto public string Source { get; init; } = string.Empty; /// - /// Подпись источника («правила»/«ML»/«ИИ»/«система», см. ). + /// Подпись источника («правила»/«ML»/«ИИ»/«система», см. ). /// public string SourceLabel { get; init; } = string.Empty; diff --git a/src/core/Deal.Modules.Pipeline/Application/Parse/LocalFieldsParser.cs b/src/core/Deal.Modules.Pipeline/Application/Parse/LocalFieldsParser.cs index 09b4a4b..c9a2656 100644 --- a/src/core/Deal.Modules.Pipeline/Application/Parse/LocalFieldsParser.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Parse/LocalFieldsParser.cs @@ -2,7 +2,10 @@ using System.Text.RegularExpressions; using Deal.Modules.Kanban.Application.ColumnRules; using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Modules.Pipeline.Application.Parse; diff --git a/src/core/Deal.Modules.Pipeline/Application/PipelineModuleRegistrar.cs b/src/core/Deal.Modules.Pipeline/Application/Registrars/PipelineModuleRegistrar.cs similarity index 96% rename from src/core/Deal.Modules.Pipeline/Application/PipelineModuleRegistrar.cs rename to src/core/Deal.Modules.Pipeline/Application/Registrars/PipelineModuleRegistrar.cs index c0f9eeb..3928c49 100644 --- a/src/core/Deal.Modules.Pipeline/Application/PipelineModuleRegistrar.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Registrars/PipelineModuleRegistrar.cs @@ -1,7 +1,10 @@ using Deal.Modules.Pipeline.Application.Parse; using Microsoft.Extensions.DependencyInjection; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Services; -namespace Deal.Modules.Pipeline.Application; +namespace Deal.Modules.Pipeline.Application.Registrars; /// /// DI-регистрация модуля Pipeline. Паттерн «port & adapter» (Ruling 10). diff --git a/src/core/Deal.Modules.Pipeline/Application/AiCardLearning.cs b/src/core/Deal.Modules.Pipeline/Application/Services/AiCardLearning.cs similarity index 89% rename from src/core/Deal.Modules.Pipeline/Application/AiCardLearning.cs rename to src/core/Deal.Modules.Pipeline/Application/Services/AiCardLearning.cs index b31cda9..64716b2 100644 --- a/src/core/Deal.Modules.Pipeline/Application/AiCardLearning.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/AiCardLearning.cs @@ -1,63 +1,69 @@ -using Deal.Contracts.Integrations; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.ColumnRules; -using Deal.Modules.Kanban.Application.Models; - -namespace Deal.Modules.Pipeline.Application; - -/// -/// Обучающие сигналы ML по карточке ИИ-пути (pipeline.py L1155–1180): колонка-доска и тип заявки. -/// -/// -/// Общий источник для воркера pump () и ручной переклассификации -/// (): оба «докладывают» ML те же сигналы гипотезы ИИ с весом -/// , поэтому логика вынесена из воркера без дублей. -/// Свободная колонка — не служебная (inbox/trash/archive), не ИИ-предложение и без активных правил: -/// именно такие доски ML может назначать сама (собираем аналогичные примеры). -/// -public static class AiCardLearning -{ - /// - /// Пушит обучающие сигналы ML: доска (свободная колонка) и тип (при известном типе). - /// - /// Порт канбана: чтение правил/признака предложения доски. - /// Клиент ML (PushAsync — обучающий сигнал). - /// Колонка карточки после классификации (реальная, после ContainerAccepts-страховки). - /// Разбор, на котором собрана карточка (тип/спам из классификатора). - /// Текст сообщения (обучающий пример — как source_msg карточки). - /// Вес сигнала (гипотеза ИИ — ). - /// Токен отмены. - public static async Task PushSignalsAsync( - ICardStore kanjStore, - IMlClient mlClient, - string col, - AiParsedCardDto parsed, - string text, - double weight, - CancellationToken ct) - { - bool isServiceCol = col == CardIds.Inbox || col == CardIds.Trash || col == CardIds.Archive; - if (!isServiceCol && !parsed.IsSpam) - { - ContainerDto? board = await kanjStore.GetContainerAsync(col, ct); - bool free = board is not null && !board.Suggested && !ColumnRules.HasActiveRules(board.Rules); - if (free) - { - await mlClient.PushAsync(text, col, weight, ct); - } - } - - if (parsed.IsVacancyKnown) - { - await mlClient.PushAsync( - text, - parsed.IsVacancy ? MlLearningLabels.TypeHireValue : MlLearningLabels.TypeOrderValue, - weight, - ct); - } - } -} +using Deal.Contracts.Integrations; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Cards.Application.Abstractions; +using Deal.Modules.Cards.Application.Dtos; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Kanban.Application.ColumnRules; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Обучающие сигналы ML по карточке ИИ-пути (pipeline.py L1155–1180): колонка-доска и тип заявки. +/// +/// +/// Общий источник для воркера pump () и ручной переклассификации +/// (): оба «докладывают» ML те же сигналы гипотезы ИИ с весом +/// , поэтому логика вынесена из воркера без дублей. +/// Свободная колонка — не служебная (inbox/trash/archive), не ИИ-предложение и без активных правил: +/// именно такие доски ML может назначать сама (собираем аналогичные примеры). +/// +public static class AiCardLearning +{ + /// + /// Пушит обучающие сигналы ML: доска (свободная колонка) и тип (при известном типе). + /// + /// Порт канбана: чтение правил/признака предложения доски. + /// Клиент ML (PushAsync — обучающий сигнал). + /// Колонка карточки после классификации (реальная, после ContainerAccepts-страховки). + /// Разбор, на котором собрана карточка (тип/спам из классификатора). + /// Текст сообщения (обучающий пример — как source_msg карточки). + /// Вес сигнала (гипотеза ИИ — ). + /// Токен отмены. + public static async Task PushSignalsAsync( + ICardStore kanjStore, + IMlClient mlClient, + string col, + AiParsedCardDto parsed, + string text, + double weight, + CancellationToken ct) + { + bool isServiceCol = col == CardIds.Inbox || col == CardIds.Trash || col == CardIds.Archive; + if (!isServiceCol && !parsed.IsSpam) + { + ContainerDto? board = await kanjStore.GetContainerAsync(col, ct); + bool free = board is not null && !board.Suggested && !ColumnRules.HasActiveRules(board.Rules); + if (free) + { + await mlClient.PushAsync(text, col, weight, ct); + } + } + + if (parsed.IsVacancyKnown) + { + await mlClient.PushAsync( + text, + parsed.IsVacancy ? MlLearningLabels.TypeHireValue : MlLearningLabels.TypeOrderValue, + weight, + ct); + } + } +} diff --git a/src/core/Deal.Modules.Pipeline/Application/AiCardMapper.cs b/src/core/Deal.Modules.Pipeline/Application/Services/AiCardMapper.cs similarity index 92% rename from src/core/Deal.Modules.Pipeline/Application/AiCardMapper.cs rename to src/core/Deal.Modules.Pipeline/Application/Services/AiCardMapper.cs index 0292f64..a2ec164 100644 --- a/src/core/Deal.Modules.Pipeline/Application/AiCardMapper.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/AiCardMapper.cs @@ -1,10 +1,15 @@ using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Parse; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Registrars; -namespace Deal.Modules.Pipeline.Application; +namespace Deal.Modules.Pipeline.Application.Services; /// /// Маппинг локального разбора в контрактный (pipeline.py _local_fields L718–798 → raw-словарь карточки). diff --git a/src/core/Deal.Modules.Pipeline/Application/AiClassifyContextBuilder.cs b/src/core/Deal.Modules.Pipeline/Application/Services/AiClassifyContextBuilder.cs similarity index 94% rename from src/core/Deal.Modules.Pipeline/Application/AiClassifyContextBuilder.cs rename to src/core/Deal.Modules.Pipeline/Application/Services/AiClassifyContextBuilder.cs index 515f4a0..0be7972 100644 --- a/src/core/Deal.Modules.Pipeline/Application/AiClassifyContextBuilder.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/AiClassifyContextBuilder.cs @@ -1,11 +1,20 @@ using System.Text; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.ColumnRules; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Kanban.Application.ColumnRules; using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; -namespace Deal.Modules.Pipeline.Application; +namespace Deal.Modules.Pipeline.Application.Services; /// /// Сборка контекста ИИ-классификации из настроек и данных тенанта (план Task 15, Ruling 5; diff --git a/src/core/Deal.Modules.Pipeline/Application/AiRawCardMapper.cs b/src/core/Deal.Modules.Pipeline/Application/Services/AiRawCardMapper.cs similarity index 97% rename from src/core/Deal.Modules.Pipeline/Application/AiRawCardMapper.cs rename to src/core/Deal.Modules.Pipeline/Application/Services/AiRawCardMapper.cs index 5de111d..9432a57 100644 --- a/src/core/Deal.Modules.Pipeline/Application/AiRawCardMapper.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/AiRawCardMapper.cs @@ -2,12 +2,17 @@ using System.Globalization; using System.Text.Json; using System.Text.Json.Nodes; using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Parse; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Registrars; -namespace Deal.Modules.Pipeline.Application; +namespace Deal.Modules.Pipeline.Application.Services; /// /// Строгий маппинг JSON-ответа ИИ-классификатора в контрактный разбор карточки (план Task 15, Ruling 5; diff --git a/src/core/Deal.Modules.Pipeline/Application/CardComposer.cs b/src/core/Deal.Modules.Pipeline/Application/Services/CardComposer.cs similarity index 95% rename from src/core/Deal.Modules.Pipeline/Application/CardComposer.cs rename to src/core/Deal.Modules.Pipeline/Application/Services/CardComposer.cs index feb1b9b..f8faade 100644 --- a/src/core/Deal.Modules.Pipeline/Application/CardComposer.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/CardComposer.cs @@ -1,201 +1,209 @@ -using Deal.Contracts.Integrations; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.ColumnRules; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application; - -namespace Deal.Modules.Pipeline.Application; - -/// -/// Сборка снимка карточки из разобранного сообщения для записи через Kanban (план Task 7 L393–399, -/// Ruling 4; python pipeline.py _store_lead L433–514). -/// -/// -/// Чистый класс модуля Pipeline (без EF/HTTP): из разбора (ИИ/локальный путь) -/// и строки-сообщения собирает полный 1:1 с _store_lead: -/// title — (140, fallback — начало исходника); «О заявке» — -/// (блоки Компания → … → Условия, 1:1 с cardPrompt/compose_summary -/// L225–284) через (2000, fallback — clean_short исходника); -/// stack — (≤12); бюджет — -/// из разбора (форма хранения) + fallback первой суммы по -/// исходнику/«О заявке» (L459–468); конверсия один раз при поступлении — -/// (conversionOn/targetCurrency/ratesCache из типизированного снимка TenantSettingsSnapshot, C30, с мок-фолбэком); -/// контакты — из значений разбора или текста (L389–421, ≤6, дедуп), -/// contact = (L424–430, ≤200); ch-поля канала, sourceMsg = text[:4000], -/// prevCol=inbox, isVacancy/isVacancyKnown из разбора. -/// -/// Колонка: разбор может назначить доску (parsed.Board) — читает её через -/// и применяет страховку (python -/// L449–450): доска отсутствует или текст не прошёл правила → col=inbox (ИИ/ML не кладут в отфильтрованную -/// колонку); прошла → col=доска, matchHits = для прошедшей доски (иначе -/// пусто). Id карточки (c_) генерирует вызывающий (PipelineCardWriter) и передаёт готовым (Ruling 12). -/// -/// -public sealed class CardComposer(ICardStore kanjStore, ISettingsStore settings) -{ - // Лимит заголовка карточки (python _store_lead L455: clean_short(title, 140)). - private const int MaxTitleCodePoints = 140; - - // Лимит блока «О заявке» (python L458: clean_block(summary, 2000)). - private const int MaxSummaryCodePoints = 2000; - - // Лимит исходного сообщения на карточке (python L503: text[:4000]). - private const int MaxSourceMsgCodePoints = 4000; - - // Лимит «быстрого» контакта карточки (python L471: primary_contact(contacts)[:200]). - private const int MaxPrimaryContactCodePoints = 200; - - /// - /// Собирает полный снимок новой карточки из разбора и строки сообщения (1:1 с _store_lead L433–514). - /// - /// - /// Использует только поля-сообщения QueueItemDto (DialogId/Channel/Text/MsgId/MsgAtMs) — статус/время - /// постановки строки на карточку не влияют. «Повтор по дедупу» здесь не проверяется (этап воркера, - /// Ruling 8): вызывающий (PipelineCardWriter/воркер) уже заявил хэш и связал карточку после записи. - /// - /// Разбор сообщения (ИИ-классификатор или локальный путь; контакты квалифицированы). - /// Строка очереди с сообщением-источником (метаданные канала, текст, время). - /// Готовый id карточки (c_...; генерирует PipelineCardWriter через PrefixId). - /// Токен отмены. - /// Полный снимок карточки для (CreatedAt проставит хранилище). - public async Task BuildAsync( - AiParsedCardDto parsed, - QueueItemDto message, - string cardId, - CancellationToken ct) - { - // «О заявке» всегда собирается из одинаковых блоков (Компания → … → Условия); структуры нет — суть - // как есть либо «О задаче: …» из исходника (SummaryComposer.Compose), сверху clean_block 2000. - ParsedCardContent content = ToParsedContent(parsed); - string summary = MessageTextCleaner.CleanBlock(SummaryComposer.Compose(content, message.Text), MaxSummaryCodePoints); - if (summary.Length == 0) - { - summary = MessageTextCleaner.CleanShort(message.Text, MaxSummaryCodePoints); // python L458 fallback - } - - string title = MessageTextCleaner.CleanShort(parsed.Title, MaxTitleCodePoints); - if (title.Length == 0) - { - title = MessageTextCleaner.CleanShort(message.Text, MaxTitleCodePoints); // python L455 fallback - } - - IReadOnlyList stack = MessageListNormalizer.NormalizeStack(parsed.Stack); - CardBudgetDto? budget = ComposeBudget(parsed.Budget, message.Text, summary); - - // Доска разбора (страховка ContainerAccepts) и конверсия бюджета требуют курсы: типизированный снимок - // настроек читается ОДИН раз на карточку (C30) — мок-фолбэк при отсутствии кэша, как раньше LoadRatesAsync. - string? boardCandidate = string.IsNullOrWhiteSpace(parsed.Board) ? null : parsed.Board.Trim(); - TenantSettingsSnapshot? settingsSnapshot = budget is not null || boardCandidate is not null - ? await TenantSettingsSnapshot.LoadAsync(settings, ct) - : null; - IReadOnlyDictionary? rates = null; - if (settingsSnapshot is not null) - { - rates = settingsSnapshot.TryGetRatesCache()?.Rates ?? MockRates.Values; - } - - CardBudgetDto? converted = null; - if (budget is not null) - { - bool conversionOn = settingsSnapshot!.GetBool(SettingsKeys.ConversionOn, SettingsDefaults.ConversionOn); - string targetCurrency = NormalizeTargetCurrency( - settingsSnapshot.GetString(SettingsKeys.TargetCurrency, SettingsDefaults.TargetCurrency)); - converted = BudgetNormalizer.ToTarget(budget, conversionOn, targetCurrency, rates); - } - - // Колонка и «почему карточка здесь»: страховка L449–450 (доски нет/не прошла правила → inbox). - string col = CardIds.Inbox; - IReadOnlyList matchHits = Array.Empty(); - if (boardCandidate is not null) - { - ContainerDto? board = await kanjStore.GetContainerAsync(boardCandidate, ct); - if (board is not null && ColumnRules.ContainerAccepts(board.Rules, message.Text, rates)) - { - col = board.Id; - matchHits = ColumnRules.ComputeHits(board.Rules, message.Text, rates); - } - } - - IReadOnlyList contacts = ContactsQualifier.Build( - parsed.Contacts.Select(contact => contact.Value).ToList(), message.Text); - string contact = MessageTextCleaner.SliceCodePoints(ContactsQualifier.Primary(contacts), MaxPrimaryContactCodePoints); - string hue = string.IsNullOrWhiteSpace(message.Channel.Hue) ? SourceDefaults.DefaultHue : message.Channel.Hue; - DateTimeOffset receivedAt = message.MsgAtMs != 0 - ? DateTimeOffset.FromUnixTimeMilliseconds(message.MsgAtMs) - : DateTimeOffset.UtcNow; // python row.get("msg_at") or now (L501) - - return new CardSnapshot - { - Id = cardId, - Col = col, - IsNew = true, // создание — точка «новое» (Ruling 2) - IsVacancy = parsed.IsVacancy, - IsVacancyKnown = parsed.IsVacancyKnown, - Title = title, - Summary = summary, - Stack = stack, - BudgetFrom = budget?.From, - BudgetTo = budget?.To, - BudgetCur = budget?.Cur ?? string.Empty, - ConvFrom = converted?.From, - ConvTo = converted?.To, - ConvCur = converted?.Cur ?? string.Empty, - Contact = contact, - Contacts = contacts, - ChannelName = message.Channel.Name, - ChannelHandle = message.Channel.Handle, - ChannelHue = hue, - ReceivedAt = receivedAt, - SourceMsg = MessageTextCleaner.SliceCodePoints(message.Text, MaxSourceMsgCodePoints), - SourceDialogId = message.DialogId, - SourceMsgId = message.MsgId, - PrevCol = CardIds.Inbox, - ArchivedAt = null, - MatchHits = matchHits, - }; - } - - // Бюджет карточки: нормализация из разбора, иначе fallback первой суммы по исходнику/«О заявке» - // (python L453/L459–468; формы хранения — CardBudgetDto). - // parsedBudget: Бюджет разбора (форма контракта; null — разбор не выделил сумму). - // text: Текст исходного сообщения (первый источник fallback). - // summary: Блок «О заявке» (второй источник fallback — сумма часто уходит в «Условия»). - // Возвращает: Нормализованный бюджет либо null — суммы с валютой нет ни в разборе, ни в тексте. - private static CardBudgetDto? ComposeBudget(AiBudgetDto? parsedBudget, string? text, string summary) - { - if (parsedBudget is not null) - { - return BudgetNormalizer.Normalize(new BudgetRangeDto(parsedBudget.From, parsedBudget.To, parsedBudget.Cur)); - } - - BudgetRangeDto? fallback = AmountRangeBudgetFallback.Extract(text, summary); - return fallback is null ? null : BudgetNormalizer.Normalize(fallback); - } - - // Маппинг разбора классификатора в структуру блока «О заявке» (поля 1:1 с cardPrompt L116–123). - // parsed: Разбор (пустые/отсутствующие поля блок не дают — SummaryComposer). - // Возвращает: Структура для SummaryComposer.Compose. - private static ParsedCardContent ToParsedContent(AiParsedCardDto parsed) => new( - Company: parsed.Company, - Format: parsed.Format, - Task: parsed.Task, - Requirements: parsed.Requirements, - Plus: parsed.Plus, - Conditions: parsed.Conditions, - Summary: parsed.Summary); - - // Код целевой валюты для конверсии: trim + верхний регистр; пусто → дефолт RUB (как писал PATCH). - // value: Значение настройки targetCurrency (JSON-строка). - // Возвращает: Код валюты (RUB/USD/…) либо дефолт. - private static string NormalizeTargetCurrency(string value) - { - string currency = value.Trim().ToUpperInvariant(); - return currency.Length > 0 ? currency : SettingsDefaults.TargetCurrency; - } -} +using Deal.Contracts.Integrations; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Cards.Application.Abstractions; +using Deal.Modules.Cards.Application.Dtos; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Kanban.Application.ColumnRules; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Parse; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Registrars; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Сборка снимка карточки из разобранного сообщения для записи через Kanban (план Task 7 L393–399, +/// Ruling 4; python pipeline.py _store_lead L433–514). +/// +/// +/// Чистый класс модуля Pipeline (без EF/HTTP): из разбора (ИИ/локальный путь) +/// и строки-сообщения собирает полный 1:1 с _store_lead: +/// title — (140, fallback — начало исходника); «О заявке» — +/// (блоки Компания → … → Условия, 1:1 с cardPrompt/compose_summary +/// L225–284) через (2000, fallback — clean_short исходника); +/// stack — (≤12); бюджет — +/// из разбора (форма хранения) + fallback первой суммы по +/// исходнику/«О заявке» (L459–468); конверсия один раз при поступлении — +/// (conversionOn/targetCurrency/ratesCache из типизированного снимка TenantSettingsSnapshot, C30, с мок-фолбэком); +/// контакты — из значений разбора или текста (L389–421, ≤6, дедуп), +/// contact = (L424–430, ≤200); ch-поля канала, sourceMsg = text[:4000], +/// prevCol=inbox, isVacancy/isVacancyKnown из разбора. +/// +/// Колонка: разбор может назначить доску (parsed.Board) — читает её через +/// и применяет страховку (python +/// L449–450): доска отсутствует или текст не прошёл правила → col=inbox (ИИ/ML не кладут в отфильтрованную +/// колонку); прошла → col=доска, matchHits = для прошедшей доски (иначе +/// пусто). Id карточки (c_) генерирует вызывающий (PipelineCardWriter) и передаёт готовым (Ruling 12). +/// +/// +public sealed class CardComposer(ICardStore kanjStore, ISettingsStore settings) +{ + // Лимит заголовка карточки (python _store_lead L455: clean_short(title, 140)). + private const int MaxTitleCodePoints = 140; + + // Лимит блока «О заявке» (python L458: clean_block(summary, 2000)). + private const int MaxSummaryCodePoints = 2000; + + // Лимит исходного сообщения на карточке (python L503: text[:4000]). + private const int MaxSourceMsgCodePoints = 4000; + + // Лимит «быстрого» контакта карточки (python L471: primary_contact(contacts)[:200]). + private const int MaxPrimaryContactCodePoints = 200; + + /// + /// Собирает полный снимок новой карточки из разбора и строки сообщения (1:1 с _store_lead L433–514). + /// + /// + /// Использует только поля-сообщения QueueItemDto (DialogId/Channel/Text/MsgId/MsgAtMs) — статус/время + /// постановки строки на карточку не влияют. «Повтор по дедупу» здесь не проверяется (этап воркера, + /// Ruling 8): вызывающий (PipelineCardWriter/воркер) уже заявил хэш и связал карточку после записи. + /// + /// Разбор сообщения (ИИ-классификатор или локальный путь; контакты квалифицированы). + /// Строка очереди с сообщением-источником (метаданные канала, текст, время). + /// Готовый id карточки (c_...; генерирует PipelineCardWriter через PrefixId). + /// Токен отмены. + /// Полный снимок карточки для (CreatedAt проставит хранилище). + public async Task BuildAsync( + AiParsedCardDto parsed, + QueueItemDto message, + string cardId, + CancellationToken ct) + { + // «О заявке» всегда собирается из одинаковых блоков (Компания → … → Условия); структуры нет — суть + // как есть либо «О задаче: …» из исходника (SummaryComposer.Compose), сверху clean_block 2000. + ParsedCardContent content = ToParsedContent(parsed); + string summary = MessageTextCleaner.CleanBlock(SummaryComposer.Compose(content, message.Text), MaxSummaryCodePoints); + if (summary.Length == 0) + { + summary = MessageTextCleaner.CleanShort(message.Text, MaxSummaryCodePoints); // python L458 fallback + } + + string title = MessageTextCleaner.CleanShort(parsed.Title, MaxTitleCodePoints); + if (title.Length == 0) + { + title = MessageTextCleaner.CleanShort(message.Text, MaxTitleCodePoints); // python L455 fallback + } + + IReadOnlyList stack = MessageListNormalizer.NormalizeStack(parsed.Stack); + CardBudgetDto? budget = ComposeBudget(parsed.Budget, message.Text, summary); + + // Доска разбора (страховка ContainerAccepts) и конверсия бюджета требуют курсы: типизированный снимок + // настроек читается ОДИН раз на карточку (C30) — мок-фолбэк при отсутствии кэша, как раньше LoadRatesAsync. + string? boardCandidate = string.IsNullOrWhiteSpace(parsed.Board) ? null : parsed.Board.Trim(); + TenantSettingsSnapshot? settingsSnapshot = budget is not null || boardCandidate is not null + ? await TenantSettingsSnapshot.LoadAsync(settings, ct) + : null; + IReadOnlyDictionary? rates = null; + if (settingsSnapshot is not null) + { + rates = settingsSnapshot.TryGetRatesCache()?.Rates ?? MockRates.Values; + } + + CardBudgetDto? converted = null; + if (budget is not null) + { + bool conversionOn = settingsSnapshot!.GetBool(SettingsKeys.ConversionOn, SettingsDefaults.ConversionOn); + string targetCurrency = NormalizeTargetCurrency( + settingsSnapshot.GetString(SettingsKeys.TargetCurrency, SettingsDefaults.TargetCurrency)); + converted = BudgetNormalizer.ToTarget(budget, conversionOn, targetCurrency, rates); + } + + // Колонка и «почему карточка здесь»: страховка L449–450 (доски нет/не прошла правила → inbox). + string col = CardIds.Inbox; + IReadOnlyList matchHits = Array.Empty(); + if (boardCandidate is not null) + { + ContainerDto? board = await kanjStore.GetContainerAsync(boardCandidate, ct); + if (board is not null && ColumnRules.ContainerAccepts(board.Rules, message.Text, rates)) + { + col = board.Id; + matchHits = ColumnRules.ComputeHits(board.Rules, message.Text, rates); + } + } + + IReadOnlyList contacts = ContactsQualifier.Build( + parsed.Contacts.Select(contact => contact.Value).ToList(), message.Text); + string contact = MessageTextCleaner.SliceCodePoints(ContactsQualifier.Primary(contacts), MaxPrimaryContactCodePoints); + string hue = string.IsNullOrWhiteSpace(message.Channel.Hue) ? SourceDefaults.DefaultHue : message.Channel.Hue; + DateTimeOffset receivedAt = message.MsgAtMs != 0 + ? DateTimeOffset.FromUnixTimeMilliseconds(message.MsgAtMs) + : DateTimeOffset.UtcNow; // python row.get("msg_at") or now (L501) + + return new CardSnapshot + { + Id = cardId, + Col = col, + IsNew = true, // создание — точка «новое» (Ruling 2) + IsVacancy = parsed.IsVacancy, + IsVacancyKnown = parsed.IsVacancyKnown, + Title = title, + Summary = summary, + Stack = stack, + BudgetFrom = budget?.From, + BudgetTo = budget?.To, + BudgetCur = budget?.Cur ?? string.Empty, + ConvFrom = converted?.From, + ConvTo = converted?.To, + ConvCur = converted?.Cur ?? string.Empty, + Contact = contact, + Contacts = contacts, + ChannelName = message.Channel.Name, + ChannelHandle = message.Channel.Handle, + ChannelHue = hue, + ReceivedAt = receivedAt, + SourceMsg = MessageTextCleaner.SliceCodePoints(message.Text, MaxSourceMsgCodePoints), + SourceDialogId = message.DialogId, + SourceMsgId = message.MsgId, + PrevCol = CardIds.Inbox, + ArchivedAt = null, + MatchHits = matchHits, + }; + } + + // Бюджет карточки: нормализация из разбора, иначе fallback первой суммы по исходнику/«О заявке» + // (python L453/L459–468; формы хранения — CardBudgetDto). + // parsedBudget: Бюджет разбора (форма контракта; null — разбор не выделил сумму). + // text: Текст исходного сообщения (первый источник fallback). + // summary: Блок «О заявке» (второй источник fallback — сумма часто уходит в «Условия»). + // Возвращает: Нормализованный бюджет либо null — суммы с валютой нет ни в разборе, ни в тексте. + private static CardBudgetDto? ComposeBudget(AiBudgetDto? parsedBudget, string? text, string summary) + { + if (parsedBudget is not null) + { + return BudgetNormalizer.Normalize(new BudgetRangeDto(parsedBudget.From, parsedBudget.To, parsedBudget.Cur)); + } + + BudgetRangeDto? fallback = AmountRangeBudgetFallback.Extract(text, summary); + return fallback is null ? null : BudgetNormalizer.Normalize(fallback); + } + + // Маппинг разбора классификатора в структуру блока «О заявке» (поля 1:1 с cardPrompt L116–123). + // parsed: Разбор (пустые/отсутствующие поля блок не дают — SummaryComposer). + // Возвращает: Структура для SummaryComposer.Compose. + private static ParsedCardContent ToParsedContent(AiParsedCardDto parsed) => new( + Company: parsed.Company, + Format: parsed.Format, + Task: parsed.Task, + Requirements: parsed.Requirements, + Plus: parsed.Plus, + Conditions: parsed.Conditions, + Summary: parsed.Summary); + + // Код целевой валюты для конверсии: trim + верхний регистр; пусто → дефолт RUB (как писал PATCH). + // value: Значение настройки targetCurrency (JSON-строка). + // Возвращает: Код валюты (RUB/USD/…) либо дефолт. + private static string NormalizeTargetCurrency(string value) + { + string currency = value.Trim().ToUpperInvariant(); + return currency.Length > 0 ? currency : SettingsDefaults.TargetCurrency; + } +} diff --git a/src/core/Deal.Modules.Pipeline/Application/CardReclassifier.cs b/src/core/Deal.Modules.Pipeline/Application/Services/CardReclassifier.cs similarity index 97% rename from src/core/Deal.Modules.Pipeline/Application/CardReclassifier.cs rename to src/core/Deal.Modules.Pipeline/Application/Services/CardReclassifier.cs index b69a8f1..f2521ba 100644 --- a/src/core/Deal.Modules.Pipeline/Application/CardReclassifier.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/CardReclassifier.cs @@ -1,377 +1,384 @@ -using Deal.Contracts.Integrations; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Modules.Pipeline.Application; - -/// -/// Ручная переклассификация карточек: повторный прогон через тот же конвейер, что и пайплайн -/// (leads.py reclassify_lead L292–389 + reclassify_inbox L392–417), но без создания новой карточки. -/// -/// -/// Для каждой карточки повторяются шаги «filtered»-прохода воркера: ИИ-фильтр (при включённом ИИ) → -/// классификация через → отсев спама в корзину с обучением ML → сборка контента -/// (заголовок/«О заявке»/стек/бюджет/контакты) → страховка ContainerAccepts → -/// обновление карточки одним запросом и обучающие сигналы ML (). -/// -/// Путь без ИИ (выключен настройкой aiEnabled) и сбой классификатора — локальный детерминированный -/// разбор ( + , как ветка воркера aiEnabled=false/ -/// aiFail): сервис не падает без кредов/сервиса ИИ. ИИ-фильтр уважает выключатель aiFilterEnabled. -/// -/// -/// Проход синхронный, одна переклассификация за раз — (состояние singleton); -/// занятый проход отвечает busy без ожидания. Токены ИИ-пути учитывает сам адаптер -/// GrpcAiClassifier через TokenUsageRecorder — отдельного учёта переклассификации не нужно. -/// -/// -/// Единый порт хранилища карточек (чтение inbox, обновление полей классификации). -/// KV-хранилище настроек тенанта (выключатели aiEnabled/aiFilterEnabled, маркеры парсера). -/// Порт ИИ: фильтр и классификация (в Local-режиме — детерминированный). -/// Локальный структуратор (путь без ИИ / сбой классификатора). -/// Сборка контента карточки (заголовок/суть/стек/бюджет/контакты/колонка). -/// Доменные операции карточки (перенос в корзину без дублей логики). -/// Клиент ML: обучающие сигналы переклассификации (вес гипотезы ИИ). -/// Single-flight-замок переклассификации (одна за раз). -public sealed class CardReclassifier( - ICardStore store, - ISettingsStore settings, - IAiClassifier aiClassifier, - LocalFieldsParser fieldsParser, - CardComposer composer, - CardsService cardsService, - IMlClient mlClient, - ReclassifyGate gate) -{ - /// - /// Причина: в «Неразобранном» нет карточек для переклассификации (пустой target). - /// - public const string EmptyInboxReason = "В «Неразобранном» нет карточек для переклассификации"; - - /// - /// Причина: у карточки нет исходного текста (переклассифицировать нечего). - /// - public const string NoSourceTextReason = "У карточки нет исходного текста для переклассификации"; - - // Ответ «фильтр пропущен» (выключен/сбой/путь без ИИ: python L1097–1106). - private static readonly AiFilterResultDto PassSkipped = new(Pass: true, Reason: null, Skipped: true); - - /// - /// Пакетная переклассификация «Неразобранного»: все карточки inbox либо пересечение с ids. - /// - /// Порядок обхода — как отдаёт хранилище (received_at DESC); ids ограничивает выборку. - /// Опциональный список id (null/пусто — все карточки inbox). - /// Токен отмены. - /// Итог прохода (счётчики исхода) либо busy, если проход уже идёт. - public async Task ReclassifyInboxAsync(IReadOnlyList? ids, CancellationToken ct) - { - if (!gate.TryEnter()) - { - return Busy(); - } - - try - { - Pass pass = await CreatePassAsync(ct); - IReadOnlyList inbox = await store.ListCardsAsync(new CardsQuery(CardIds.Inbox), ct); - List target = SelectTarget(inbox, ids); - if (target.Count == 0) - { - return Build(pass, attempted: 0, started: false, reason: EmptyInboxReason); - } - - foreach (CardDto card in target) - { - await ReclassifyOneAsync(card, pass, ct); - } - - return Build(pass, attempted: target.Count, started: true, reason: null); - } - finally - { - gate.Exit(); - } - } - - /// - /// Переклассификация одной карточки (любой колонки; обычно — «Неразобранное»). - /// - /// Карточка (уже прочитана вызывающим — 404 остаётся за эндпоинтом). - /// Токен отмены. - /// Итог прохода либо busy, если проход уже идёт. - public async Task ReclassifyCardAsync(CardDto card, CancellationToken ct) - { - if (!gate.TryEnter()) - { - return Busy(); - } - - try - { - Pass pass = await CreatePassAsync(ct); - if (string.IsNullOrWhiteSpace(card.SourceMsg)) - { - pass.Skipped++; - return Build(pass, attempted: 1, started: false, reason: NoSourceTextReason); - } - - await ReclassifyOneAsync(card, pass, ct); - return Build(pass, attempted: 1, started: true, reason: null); - } - finally - { - gate.Exit(); - } - } - - // ── Проход одной карточки ────────────────────────────────────────────── - - // Прогоняет одну карточку по конвейеру переклассификации (фильтр → разбор → сборка → запись → ML). - // Спам/непройденный фильтр отправляют карточку в корзину; остальные обновляются результатом разбора. - // card: Карточка. - // pass: Накопители прохода (настройки/счётчики/признак ИИ). - // ct: Токен отмены. - private async Task ReclassifyOneAsync(CardDto card, Pass pass, CancellationToken ct) - { - string text = card.SourceMsg; - if (string.IsNullOrWhiteSpace(text)) - { - pass.Skipped++; - return; - } - - AiParsedCardDto? parsed = null; - if (pass.AiEnabled) - { - AiFilterResultDto filter = pass.AiFilterEnabled ? await FilterSafelyAsync(text, ct) : PassSkipped; - if (!filter.Pass) - { - await TrashAsync(card, text, pass, ct); - return; - } - - try - { - parsed = await aiClassifier.ClassifyAsync(text, ct); - } - catch (Exception) - { - // Классификатор недоступен/сбой — локальный разбор (как raw={} python L1112–1114). - parsed = null; - } - - if (parsed is not null) - { - // Успешная классификация ИИ подтверждает тип по контексту (python L1108–1111). - parsed = parsed with { IsVacancyKnown = true }; - pass.AiUsed = true; - } - } - - if (parsed is null) - { - parsed = AiCardMapper.FromLocal(fieldsParser.Parse(text, pass.Snapshot), text); - } - - if (parsed.IsSpam) - { - await TrashAsync(card, text, pass, ct); - return; - } - - CardSnapshot snapshot = await composer.BuildAsync(parsed, BuildMessage(card, text), card.Id, ct); - await store.ApplyReclassificationAsync( - new CardReclassificationDto( - CardId: card.Id, - Col: snapshot.Col, - IsNew: true, - IsVacancy: snapshot.IsVacancy, - IsVacancyKnown: snapshot.IsVacancyKnown, - Title: snapshot.Title, - Summary: snapshot.Summary, - Stack: snapshot.Stack, - Budget: ToBudget(snapshot.BudgetFrom, snapshot.BudgetTo, snapshot.BudgetCur), - Converted: ToBudget(snapshot.ConvFrom, snapshot.ConvTo, snapshot.ConvCur), - Contact: ResolveContact(card, snapshot.Contact), - Contacts: snapshot.Contacts, - MatchHits: snapshot.MatchHits), - ct); - - if (snapshot.Col == CardIds.Inbox) - { - pass.Kept++; - } - else - { - pass.Moved++; - } - - await AiCardLearning.PushSignalsAsync(store, mlClient, snapshot.Col, parsed, text, MlLearningLabels.AiPushWeight, ct); - } - - // Отправляет карточку в корзину и обучает ML «спаму» с весом гипотезы ИИ (python L311–318). - // card: Карточка. - // text: Исходный текст (обучающий пример). - // pass: Накопители прохода. - // ct: Токен отмены. - private async Task TrashAsync(CardDto card, string text, Pass pass, CancellationToken ct) - { - // teach=false: журнал action=trash пишется, но сигнал «спам» кладём явно ниже — с весом ИИ (0.4). - await cardsService.TrashCardAsync(card.Id, teach: false, ct); - await mlClient.PushAsync(text, MlLearningLabels.Spam, MlLearningLabels.AiPushWeight, ct); - pass.Trashed++; - } - - // ИИ-фильтр со сбоем-пропуском (недоступность фильтра не прерывает переклассификацию). - // text: Текст сообщения. - // ct: Токен отмены. - // Возвращает: Решение фильтра либо «пропуск» при сбое. - private async Task FilterSafelyAsync(string text, CancellationToken ct) - { - try - { - return await aiClassifier.FilterAsync(text, ct); - } - catch (Exception) - { - return PassSkipped; - } - } - - // ── Сборка входа/выхода ──────────────────────────────────────────────── - - // Собирает строку-сообщение для CardComposer из полей карточки (канал/время/исходный текст). - // card: Карточка-источник (алиасы канала/получателя/даты). - // text: Исходный текст (source_msg). - // Возвращает: Строка очереди, эквивалентная исходному сообщению карточки. - private static QueueItemDto BuildMessage(CardDto card, string text) => new() - { - DialogId = card.SourceDialogId, - MsgId = card.SourceMsgId, - Text = text, - Channel = new PipelineChannelDto(card.Channel.Name, card.Channel.Handle, card.Channel.Hue), - MsgAtMs = card.ReceivedAtMs, - }; - - // Восстанавливает бюджет из полей снимка: пустая валюта — бюджета нет (null). - // from: Нижняя граница. - // to: Верхняя граница. - // cur: Валюта (пусто — нет). - // Возвращает: Бюджет карточки либо null. - private static CardBudgetDto? ToBudget(double? from, double? to, string cur) => - cur.Length == 0 ? null : new CardBudgetDto(from, to, cur); - - // Контакт карточки: новый из разбора, иначе — валидный старый (python L327–331). - // card: Карточка до переклассификации (старый контакт). - // computed: Контакт, собранный из нового разбора. - // Возвращает: Значение основного контакта либо пустая строка. - private static string ResolveContact(CardDto card, string computed) - { - if (computed.Length > 0) - { - return computed; - } - - string oldContact = card.Contact.Trim(); - return oldContact.Length > 0 && ContactsQualifier.Qualify(oldContact) is not null ? oldContact : string.Empty; - } - - // Отбор карточек inbox по опциональному списку id (python L394–399). - // inbox: Карточки «Неразобранного» (порядок хранилища). - // ids: Опциональный фильтр id. - // Возвращает: Целевые карточки в порядке хранилища. - private static List SelectTarget(IReadOnlyList inbox, IReadOnlyList? ids) - { - if (ids is null || ids.Count == 0) - { - return [.. inbox]; - } - - var wanted = new HashSet(ids, StringComparer.Ordinal); - var target = new List(); - foreach (CardDto card in inbox) - { - if (wanted.Contains(card.Id)) - { - target.Add(card); - } - } - - return target; - } - - // Читает настройки прохода: снимок тенанта + выключатели ИИ/ИИ-фильтра. - // ct: Токен отмены. - // Возвращает: Накопители прохода с настройками. - private async Task CreatePassAsync(CancellationToken ct) - { - TenantSettingsSnapshot snapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct); - return new Pass( - snapshot, - snapshot.GetBool(SettingsKeys.AiEnabled, SettingsDefaults.AiEnabled), - snapshot.GetBool(SettingsKeys.AiFilterEnabled, SettingsDefaults.AiFilterEnabled)); - } - - // Ответ занятости: проход уже выполняется. - // Возвращает: Итог с busy = true. - private static ReclassifyResultDto Busy() => - new(Started: false, Busy: true, Attempted: 0, Reclassified: 0, Moved: 0, Kept: 0, Trashed: 0, Skipped: 0, UsedAi: false, Reason: null); - - // Собирает итог прохода из накопителей. - // pass: Накопители прохода. - // attempted: Сколько карточек отобрано. - // started: Проход выполнен. - // reason: Причина (если проход не выполнен) либо null. - // Возвращает: Итог с полями wire-контракта. - private static ReclassifyResultDto Build(Pass pass, int attempted, bool started, string? reason) => - new( - Started: started, - Busy: false, - Attempted: attempted, - Reclassified: pass.Moved + pass.Kept + pass.Trashed, - Moved: pass.Moved, - Kept: pass.Kept, - Trashed: pass.Trashed, - Skipped: pass.Skipped, - UsedAi: pass.AiUsed, - Reason: reason); - - // Накопители одного прохода переклассификации: настройки, счётчики исхода, признак ИИ. - // Snapshot: Снимок настроек тенанта (для локального парсера). - // AiEnabled: ИИ-слот включён (фильтр/классификация через порт). - // AiFilterEnabled: ИИ-фильтр включён. - private sealed record Pass(TenantSettingsSnapshot Snapshot, bool AiEnabled, bool AiFilterEnabled) - { - /// - /// Сколько карточек ушло в смысловую колонку. - /// - public int Moved { get; set; } - - /// - /// Сколько карточек осталось в «Неразобранном». - /// - public int Kept { get; set; } - - /// - /// Сколько карточек отправлено в корзину. - /// - public int Trashed { get; set; } - - /// - /// Сколько карточек пропущено (нет исходного текста). - /// - public int Skipped { get; set; } - - /// - /// True — разбор хотя бы одной карточки выполнен через порт ИИ. - /// - public bool AiUsed { get; set; } - } -} +using Deal.Contracts.Integrations; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Cards.Application.Abstractions; +using Deal.Modules.Cards.Application.Dtos; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Parse; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Registrars; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Ручная переклассификация карточек: повторный прогон через тот же конвейер, что и пайплайн +/// (leads.py reclassify_lead L292–389 + reclassify_inbox L392–417), но без создания новой карточки. +/// +/// +/// Для каждой карточки повторяются шаги «filtered»-прохода воркера: ИИ-фильтр (при включённом ИИ) → +/// классификация через → отсев спама в корзину с обучением ML → сборка контента +/// (заголовок/«О заявке»/стек/бюджет/контакты) → страховка ContainerAccepts → +/// обновление карточки одним запросом и обучающие сигналы ML (). +/// +/// Путь без ИИ (выключен настройкой aiEnabled) и сбой классификатора — локальный детерминированный +/// разбор ( + , как ветка воркера aiEnabled=false/ +/// aiFail): сервис не падает без кредов/сервиса ИИ. ИИ-фильтр уважает выключатель aiFilterEnabled. +/// +/// +/// Проход синхронный, одна переклассификация за раз — (состояние singleton); +/// занятый проход отвечает busy без ожидания. Токены ИИ-пути учитывает сам адаптер +/// GrpcAiClassifier через TokenUsageRecorder — отдельного учёта переклассификации не нужно. +/// +/// +/// Единый порт хранилища карточек (чтение inbox, обновление полей классификации). +/// KV-хранилище настроек тенанта (выключатели aiEnabled/aiFilterEnabled, маркеры парсера). +/// Порт ИИ: фильтр и классификация (в Local-режиме — детерминированный). +/// Локальный структуратор (путь без ИИ / сбой классификатора). +/// Сборка контента карточки (заголовок/суть/стек/бюджет/контакты/колонка). +/// Доменные операции карточки (перенос в корзину без дублей логики). +/// Клиент ML: обучающие сигналы переклассификации (вес гипотезы ИИ). +/// Single-flight-замок переклассификации (одна за раз). +public sealed class CardReclassifier( + ICardStore store, + ISettingsStore settings, + IAiClassifier aiClassifier, + LocalFieldsParser fieldsParser, + CardComposer composer, + CardsService cardsService, + IMlClient mlClient, + ReclassifyGate gate) +{ + /// + /// Причина: в «Неразобранном» нет карточек для переклассификации (пустой target). + /// + public const string EmptyInboxReason = "В «Неразобранном» нет карточек для переклассификации"; + + /// + /// Причина: у карточки нет исходного текста (переклассифицировать нечего). + /// + public const string NoSourceTextReason = "У карточки нет исходного текста для переклассификации"; + + // Ответ «фильтр пропущен» (выключен/сбой/путь без ИИ: python L1097–1106). + private static readonly AiFilterResultDto PassSkipped = new(Pass: true, Reason: null, Skipped: true); + + /// + /// Пакетная переклассификация «Неразобранного»: все карточки inbox либо пересечение с ids. + /// + /// Порядок обхода — как отдаёт хранилище (received_at DESC); ids ограничивает выборку. + /// Опциональный список id (null/пусто — все карточки inbox). + /// Токен отмены. + /// Итог прохода (счётчики исхода) либо busy, если проход уже идёт. + public async Task ReclassifyInboxAsync(IReadOnlyList? ids, CancellationToken ct) + { + if (!gate.TryEnter()) + { + return Busy(); + } + + try + { + Pass pass = await CreatePassAsync(ct); + IReadOnlyList inbox = await store.ListCardsAsync(new CardsQuery(CardIds.Inbox), ct); + List target = SelectTarget(inbox, ids); + if (target.Count == 0) + { + return Build(pass, attempted: 0, started: false, reason: EmptyInboxReason); + } + + foreach (CardDto card in target) + { + await ReclassifyOneAsync(card, pass, ct); + } + + return Build(pass, attempted: target.Count, started: true, reason: null); + } + finally + { + gate.Exit(); + } + } + + /// + /// Переклассификация одной карточки (любой колонки; обычно — «Неразобранное»). + /// + /// Карточка (уже прочитана вызывающим — 404 остаётся за эндпоинтом). + /// Токен отмены. + /// Итог прохода либо busy, если проход уже идёт. + public async Task ReclassifyCardAsync(CardDto card, CancellationToken ct) + { + if (!gate.TryEnter()) + { + return Busy(); + } + + try + { + Pass pass = await CreatePassAsync(ct); + if (string.IsNullOrWhiteSpace(card.SourceMsg)) + { + pass.Skipped++; + return Build(pass, attempted: 1, started: false, reason: NoSourceTextReason); + } + + await ReclassifyOneAsync(card, pass, ct); + return Build(pass, attempted: 1, started: true, reason: null); + } + finally + { + gate.Exit(); + } + } + + // ── Проход одной карточки ────────────────────────────────────────────── + + // Прогоняет одну карточку по конвейеру переклассификации (фильтр → разбор → сборка → запись → ML). + // Спам/непройденный фильтр отправляют карточку в корзину; остальные обновляются результатом разбора. + // card: Карточка. + // pass: Накопители прохода (настройки/счётчики/признак ИИ). + // ct: Токен отмены. + private async Task ReclassifyOneAsync(CardDto card, Pass pass, CancellationToken ct) + { + string text = card.SourceMsg; + if (string.IsNullOrWhiteSpace(text)) + { + pass.Skipped++; + return; + } + + AiParsedCardDto? parsed = null; + if (pass.AiEnabled) + { + AiFilterResultDto filter = pass.AiFilterEnabled ? await FilterSafelyAsync(text, ct) : PassSkipped; + if (!filter.Pass) + { + await TrashAsync(card, text, pass, ct); + return; + } + + try + { + parsed = await aiClassifier.ClassifyAsync(text, ct); + } + catch (Exception) + { + // Классификатор недоступен/сбой — локальный разбор (как raw={} python L1112–1114). + parsed = null; + } + + if (parsed is not null) + { + // Успешная классификация ИИ подтверждает тип по контексту (python L1108–1111). + parsed = parsed with { IsVacancyKnown = true }; + pass.AiUsed = true; + } + } + + if (parsed is null) + { + parsed = AiCardMapper.FromLocal(fieldsParser.Parse(text, pass.Snapshot), text); + } + + if (parsed.IsSpam) + { + await TrashAsync(card, text, pass, ct); + return; + } + + CardSnapshot snapshot = await composer.BuildAsync(parsed, BuildMessage(card, text), card.Id, ct); + await store.ApplyReclassificationAsync( + new CardReclassificationDto( + CardId: card.Id, + Col: snapshot.Col, + IsNew: true, + IsVacancy: snapshot.IsVacancy, + IsVacancyKnown: snapshot.IsVacancyKnown, + Title: snapshot.Title, + Summary: snapshot.Summary, + Stack: snapshot.Stack, + Budget: ToBudget(snapshot.BudgetFrom, snapshot.BudgetTo, snapshot.BudgetCur), + Converted: ToBudget(snapshot.ConvFrom, snapshot.ConvTo, snapshot.ConvCur), + Contact: ResolveContact(card, snapshot.Contact), + Contacts: snapshot.Contacts, + MatchHits: snapshot.MatchHits), + ct); + + if (snapshot.Col == CardIds.Inbox) + { + pass.Kept++; + } + else + { + pass.Moved++; + } + + await AiCardLearning.PushSignalsAsync(store, mlClient, snapshot.Col, parsed, text, MlLearningLabels.AiPushWeight, ct); + } + + // Отправляет карточку в корзину и обучает ML «спаму» с весом гипотезы ИИ (python L311–318). + // card: Карточка. + // text: Исходный текст (обучающий пример). + // pass: Накопители прохода. + // ct: Токен отмены. + private async Task TrashAsync(CardDto card, string text, Pass pass, CancellationToken ct) + { + // teach=false: журнал action=trash пишется, но сигнал «спам» кладём явно ниже — с весом ИИ (0.4). + await cardsService.TrashCardAsync(card.Id, teach: false, ct); + await mlClient.PushAsync(text, MlLearningLabels.Spam, MlLearningLabels.AiPushWeight, ct); + pass.Trashed++; + } + + // ИИ-фильтр со сбоем-пропуском (недоступность фильтра не прерывает переклассификацию). + // text: Текст сообщения. + // ct: Токен отмены. + // Возвращает: Решение фильтра либо «пропуск» при сбое. + private async Task FilterSafelyAsync(string text, CancellationToken ct) + { + try + { + return await aiClassifier.FilterAsync(text, ct); + } + catch (Exception) + { + return PassSkipped; + } + } + + // ── Сборка входа/выхода ──────────────────────────────────────────────── + + // Собирает строку-сообщение для CardComposer из полей карточки (канал/время/исходный текст). + // card: Карточка-источник (алиасы канала/получателя/даты). + // text: Исходный текст (source_msg). + // Возвращает: Строка очереди, эквивалентная исходному сообщению карточки. + private static QueueItemDto BuildMessage(CardDto card, string text) => new() + { + DialogId = card.SourceDialogId, + MsgId = card.SourceMsgId, + Text = text, + Channel = new PipelineChannelDto(card.Channel.Name, card.Channel.Handle, card.Channel.Hue), + MsgAtMs = card.ReceivedAtMs, + }; + + // Восстанавливает бюджет из полей снимка: пустая валюта — бюджета нет (null). + // from: Нижняя граница. + // to: Верхняя граница. + // cur: Валюта (пусто — нет). + // Возвращает: Бюджет карточки либо null. + private static CardBudgetDto? ToBudget(double? from, double? to, string cur) => + cur.Length == 0 ? null : new CardBudgetDto(from, to, cur); + + // Контакт карточки: новый из разбора, иначе — валидный старый (python L327–331). + // card: Карточка до переклассификации (старый контакт). + // computed: Контакт, собранный из нового разбора. + // Возвращает: Значение основного контакта либо пустая строка. + private static string ResolveContact(CardDto card, string computed) + { + if (computed.Length > 0) + { + return computed; + } + + string oldContact = card.Contact.Trim(); + return oldContact.Length > 0 && ContactsQualifier.Qualify(oldContact) is not null ? oldContact : string.Empty; + } + + // Отбор карточек inbox по опциональному списку id (python L394–399). + // inbox: Карточки «Неразобранного» (порядок хранилища). + // ids: Опциональный фильтр id. + // Возвращает: Целевые карточки в порядке хранилища. + private static List SelectTarget(IReadOnlyList inbox, IReadOnlyList? ids) + { + if (ids is null || ids.Count == 0) + { + return [.. inbox]; + } + + var wanted = new HashSet(ids, StringComparer.Ordinal); + var target = new List(); + foreach (CardDto card in inbox) + { + if (wanted.Contains(card.Id)) + { + target.Add(card); + } + } + + return target; + } + + // Читает настройки прохода: снимок тенанта + выключатели ИИ/ИИ-фильтра. + // ct: Токен отмены. + // Возвращает: Накопители прохода с настройками. + private async Task CreatePassAsync(CancellationToken ct) + { + TenantSettingsSnapshot snapshot = await TenantSettingsSnapshot.LoadAsync(settings, ct); + return new Pass( + snapshot, + snapshot.GetBool(SettingsKeys.AiEnabled, SettingsDefaults.AiEnabled), + snapshot.GetBool(SettingsKeys.AiFilterEnabled, SettingsDefaults.AiFilterEnabled)); + } + + // Ответ занятости: проход уже выполняется. + // Возвращает: Итог с busy = true. + private static ReclassifyResultDto Busy() => + new(Started: false, Busy: true, Attempted: 0, Reclassified: 0, Moved: 0, Kept: 0, Trashed: 0, Skipped: 0, UsedAi: false, Reason: null); + + // Собирает итог прохода из накопителей. + // pass: Накопители прохода. + // attempted: Сколько карточек отобрано. + // started: Проход выполнен. + // reason: Причина (если проход не выполнен) либо null. + // Возвращает: Итог с полями wire-контракта. + private static ReclassifyResultDto Build(Pass pass, int attempted, bool started, string? reason) => + new( + Started: started, + Busy: false, + Attempted: attempted, + Reclassified: pass.Moved + pass.Kept + pass.Trashed, + Moved: pass.Moved, + Kept: pass.Kept, + Trashed: pass.Trashed, + Skipped: pass.Skipped, + UsedAi: pass.AiUsed, + Reason: reason); + + // Накопители одного прохода переклассификации: настройки, счётчики исхода, признак ИИ. + // Snapshot: Снимок настроек тенанта (для локального парсера). + // AiEnabled: ИИ-слот включён (фильтр/классификация через порт). + // AiFilterEnabled: ИИ-фильтр включён. + private sealed record Pass(TenantSettingsSnapshot Snapshot, bool AiEnabled, bool AiFilterEnabled) + { + /// + /// Сколько карточек ушло в смысловую колонку. + /// + public int Moved { get; set; } + + /// + /// Сколько карточек осталось в «Неразобранном». + /// + public int Kept { get; set; } + + /// + /// Сколько карточек отправлено в корзину. + /// + public int Trashed { get; set; } + + /// + /// Сколько карточек пропущено (нет исходного текста). + /// + public int Skipped { get; set; } + + /// + /// True — разбор хотя бы одной карточки выполнен через порт ИИ. + /// + public bool AiUsed { get; set; } + } +} diff --git a/src/core/Deal.Modules.Pipeline/Application/GlobalExclusionRules.cs b/src/core/Deal.Modules.Pipeline/Application/Services/GlobalExclusionRules.cs similarity index 96% rename from src/core/Deal.Modules.Pipeline/Application/GlobalExclusionRules.cs rename to src/core/Deal.Modules.Pipeline/Application/Services/GlobalExclusionRules.cs index 8f4b62f..51d7150 100644 --- a/src/core/Deal.Modules.Pipeline/Application/GlobalExclusionRules.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/GlobalExclusionRules.cs @@ -1,8 +1,10 @@ using Deal.Modules.Kanban.Application.ColumnRules; using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Registrars; -namespace Deal.Modules.Pipeline.Application; +namespace Deal.Modules.Pipeline.Application.Services; /// /// Глобальные исключения тенанта — стоп-фильтр ДО ML/ИИ (§5.14/§8, «стоп на уровне фильтров»). diff --git a/src/core/Deal.Modules.Pipeline/Application/MlReviewService.cs b/src/core/Deal.Modules.Pipeline/Application/Services/MlReviewService.cs similarity index 97% rename from src/core/Deal.Modules.Pipeline/Application/MlReviewService.cs rename to src/core/Deal.Modules.Pipeline/Application/Services/MlReviewService.cs index 489e521..fc39efd 100644 --- a/src/core/Deal.Modules.Pipeline/Application/MlReviewService.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/MlReviewService.cs @@ -1,423 +1,428 @@ -using Deal.Contracts.Integrations; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application.Models; - -namespace Deal.Modules.Pipeline.Application; - -/// -/// Ручная проверка/разметка ML на сообщениях канала (§8 ML: «проверка на сообщении/канале»). -/// -/// -/// -/// Candidates: собирает реальные сообщения-кандидаты по каналу (dialogId) либо по всей выборке, если -/// канал не задан, из трёх существующих источников тенанта — очереди обработки (), -/// отсева () и карточек (), -/// объединяя по (dialogId, msgId): карточка «перекрывает» отсев, отсев — очередь. Каждый кандидат несёт -/// исходный текст и текущий вердикт; мнение ML добавляется прогнозом . -/// -/// -/// Apply: ручное решение пользователя — «спам», «в колонку», «пропустить» — применяется через -/// существующие сервисы/ядро: обучение ML — , перенос/корзина карточки — -/// (он сам учит ML, дублирования сигналов нет), отсев сообщения из очереди — -/// . Действие 1:1 с прототипом ml_routes.py L137–171 -/// (skip/spam/board:<id>), плюс отсев ещё не обработанного сообщения и защита от неизвестной доски. -/// -/// -public sealed class MlReviewService( - IPipelineStore pipelineStore, - ICardStore cardStore, - CardsService cards, - PipelineProcessingService processing, - IMlClient mlClient) -{ - /// - /// Минимум сообщений в выборке кандидатов (кламп запроса 1..60, как прототип). - /// - public const int MinCandidates = 1; - - /// - /// Максимум сообщений в выборке кандидатов (кламп запроса 1..60, как прототип). - /// - public const int MaxCandidates = 60; - - // Размер одного чтения из очереди/отсева при объединении кандидатов. - private const int MaxScan = 500; - - // Длина текста кандидата в ответе (ml_routes.py L129: text[:600]). - private const int TextPreviewLength = 600; - - /// - /// Вердикт кандидата: по сообщению уже есть карточка. - /// - public const string VerdictCard = "card"; - - /// - /// Вердикт кандидата: сообщение в отсеве. - /// - public const string VerdictRejected = "rejected"; - - /// - /// Вердикт кандидата: сообщение ждёт обработки в очереди. - /// - public const string VerdictQueued = "queued"; - - /// - /// Действие: пропустить без обучения (ml_routes.py L144–145). - /// - public const string ActionSkip = "skip"; - - /// - /// Действие: спам — учим ML и (если есть) карточку в корзину (ml_routes.py L150–156). - /// - public const string ActionSpam = "spam"; - - /// - /// Префикс действия «в колонку»: board:<id> (ml_routes.py L157). - /// - public const string ActionBoardPrefix = "board:"; - - /// - /// 400 apply: неизвестная доска-цель (ml_routes.py L159–160). - /// - public const string UnknownBoardDetail = "Неизвестная доска"; - - /// - /// 400 apply: неизвестное действие (ml_routes.py L169–170). - /// - public const string UnknownActionDetail = "Неизвестное действие"; - - // Причина отсева при ручной разметке «спам» ещё не обработанного сообщения. - private const string ManualSpamReason = "ручная разметка ML: спам"; - - // Этап отсева при ручной разметке «спам» (отсев решением ML). - private const string ManualSpamStage = "spam_ml"; - - // Источник решения при ручной разметке. - private const string ManualSource = "ml"; - - // Вес обучающего сигнала ручной разметки — действие пользователя (ml_client.py USER_WEIGHT 1.0). - private const double UserPushWeight = 1.0; - - /// - /// Отбирает сообщения-кандидаты для проверки ML по каналу и/или размеру выборки. - /// - /// Id канала/диалога; пусто — выборка по всем источникам тенанта. - /// Сколько последних сообщений вернуть (кламп 1.., дефолт вызывающего). - /// Токен отмены. - /// Кандидаты (свежие первыми): текст, текущий вердикт и мнение ML по каждому. - public async Task> CandidatesAsync(string? dialogId, int limit, CancellationToken ct) - { - int take = Math.Clamp(limit, MinCandidates, MaxCandidates); - string dialog = (dialogId ?? string.Empty).Trim(); - - IReadOnlyList queue = await pipelineStore.ListAsync(status: null, MaxScan, ct); - IReadOnlyList rejected = await pipelineStore.ListPageAsync(offset: 0, MaxScan, ct); - IReadOnlyList cardList = await cardStore.ListCardsAsync(new CardsQuery(null), ct); - - // Объединение по (dialogId, msgId): очередь → отсев → карточка (последняя перекрывает предыдущие). - var merged = new Dictionary<(string Dialog, long MsgId), MlCandidateDto>(); - foreach (QueueItemDto row in queue) - { - if (row.MsgId is not { } msgId || !MatchesDialog(dialog, row.DialogId)) - { - continue; - } - - merged[(row.DialogId, msgId)] = BuildQueued(row, msgId); - } - - foreach (RejectedItemDto row in rejected) - { - if (row.MsgId is not { } msgId || !MatchesDialog(dialog, row.DialogId)) - { - continue; - } - - merged[(row.DialogId, msgId)] = BuildRejected(row, msgId); - } - - foreach (CardDto card in cardList) - { - if (card.SourceMsgId is not { } msgId || !MatchesDialog(dialog, card.SourceDialogId)) - { - continue; - } - - merged[(card.SourceDialogId, msgId)] = BuildCard(card, msgId); - } - - List ordered = merged.Values - .OrderByDescending(candidate => candidate.Time ?? 0) - .Take(take) - .ToList(); - - var withPredictions = new List(ordered.Count); - foreach (MlCandidateDto candidate in ordered) - { - withPredictions.Add(candidate with { Pred = await PredictSafelyAsync(candidate.Text, ct) }); - } - - return withPredictions; - } - - /// - /// Применяет ручное решение по сообщению: обучение ML + перенос/корзина/отсев. - /// - /// Id канала/диалога сообщения. - /// Id исходного сообщения. - /// Действие: skip | spam | board:<id>. - /// Токен отмены. - /// Результат решения; null — исходное сообщение не найдено (404-семантика эндпоинта). - public async Task ApplyAsync(string dialogId, long msgId, string? action, CancellationToken ct) - { - string normalized = (action ?? string.Empty).Trim(); - string dialog = (dialogId ?? string.Empty).Trim(); - - CardDto? card = await cardStore.GetCardBySourceAsync(dialog, msgId, ct); - string? text = await FindTextAsync(dialog, msgId, card, ct); - if (string.IsNullOrWhiteSpace(text)) - { - return null; // 404: исходное сообщение не найдено - } - - if (normalized == ActionSkip) - { - return new MlApplyResult(Error: null, Ok: true, Learned: false, Moved: null, LeadId: null); - } - - if (normalized == ActionSpam) - { - return await ApplySpamAsync(dialog, msgId, card, text, ct); - } - - if (normalized.StartsWith(ActionBoardPrefix, StringComparison.Ordinal)) - { - string boardId = normalized[ActionBoardPrefix.Length..].Trim(); - return await ApplyBoardAsync(dialog, msgId, boardId, card, text, ct); - } - - return new MlApplyResult(UnknownActionDetail, Ok: false, Learned: false, Moved: null, LeadId: null); - } - - // Действие «спам»: карточку — в корзину (с обучением), сообщение из очереди — в отсев; иначе учим ML. - // dialog: Id диалога. - // msgId: Id сообщения. - // card: Карточка сообщения (null — сообщение не становилось карточкой). - // text: Текст сообщения. - // ct: Токен отмены. - // Возвращает: Результат решения. - private async Task ApplySpamAsync(string dialog, long msgId, CardDto? card, string text, CancellationToken ct) - { - if (card is not null) - { - // TrashCardAsync(teach:true) сам шлёт обучающий сигнал «спам» — второй сигнал не нужен. - CardDto? trashed = await cards.TrashCardAsync(card.Id, teach: true, ct); - return new MlApplyResult(null, Ok: true, Learned: true, Moved: "trash", LeadId: trashed?.Id ?? card.Id); - } - - await mlClient.PushAsync(text, MlLearningLabels.Spam, UserPushWeight, ct); - - // Сообщение ещё в очереди — отсеиваем его (решение пользователя), снимая строку. - QueueItemDto? queued = await FindQueuedAsync(dialog, msgId, ct); - if (queued is not null) - { - await processing.RejectAsync(new RejectRecord - { - DialogId = queued.DialogId, - MsgId = queued.MsgId, - Text = queued.Text, - ChannelName = queued.Channel.Name, - ChannelHandle = queued.Channel.Handle, - ChannelHue = queued.Channel.Hue, - MsgAtMs = queued.MsgAtMs, - Source = ManualSource, - Stage = ManualSpamStage, - Reason = ManualSpamReason, - Kw = string.Empty, - }, ct); - await pipelineStore.RemoveAsync(queued.Id, ct); - } - - return new MlApplyResult(null, Ok: true, Learned: true, Moved: null, LeadId: null); - } - - // Действие «в колонку»: карточку — переносим, уже в колонке — только учим; иначе учим ML. - // dialog: Id диалога. - // msgId: Id сообщения. - // boardId: Id колонки-цели (inbox или b_...). - // card: Карточка сообщения (null — сообщение не становилось карточкой). - // text: Текст сообщения. - // ct: Токен отмены. - // Возвращает: Результат решения. - private async Task ApplyBoardAsync( - string dialog, - long msgId, - string boardId, - CardDto? card, - string text, - CancellationToken ct) - { - if (boardId != CardIds.Inbox && await cardStore.GetContainerAsync(boardId, ct) is null) - { - return new MlApplyResult(UnknownBoardDetail, Ok: false, Learned: false, Moved: null, LeadId: null); - } - - if (card is null) - { - await mlClient.PushAsync(text, boardId, UserPushWeight, ct); - return new MlApplyResult(null, Ok: true, Learned: true, Moved: null, LeadId: null); - } - - if (card.Col == boardId) - { - // Повторная разметка карточки в той же колонке — только обучение (ml_routes.py L163–165). - await mlClient.PushAsync(text, boardId, UserPushWeight, ct); - return new MlApplyResult(null, Ok: true, Learned: true, Moved: null, LeadId: card.Id); - } - - // MoveDashboardCardAsync сам учит колонку (toCol ≠ inbox) — второй сигнал не нужен. - CardResultDto moved = await cards.MoveDashboardCardAsync(card.Id, boardId, ct); - if (moved.Error is not null) - { - return new MlApplyResult(moved.Error, Ok: false, Learned: false, Moved: null, LeadId: card.Id); - } - - return new MlApplyResult(null, Ok: true, Learned: true, Moved: boardId, LeadId: card.Id); - } - - // Текст исходного сообщения: source_msg карточки, иначе текст строки очереди/записи отсева. - // dialog: Id диалога. - // msgId: Id сообщения. - // card: Карточка сообщения (уже прочитана вызывающим). - // ct: Токен отмены. - // Возвращает: Непустой текст либо null, если сообщения нет ни в одном источнике. - private async Task FindTextAsync(string dialog, long msgId, CardDto? card, CancellationToken ct) - { - if (card is not null && !string.IsNullOrWhiteSpace(card.SourceMsg)) - { - return card.SourceMsg; - } - - QueueItemDto? queued = await FindQueuedAsync(dialog, msgId, ct); - if (queued is not null && !string.IsNullOrWhiteSpace(queued.Text)) - { - return queued.Text; - } - - IReadOnlyList rejected = await pipelineStore.ListPageAsync(offset: 0, MaxScan, ct); - foreach (RejectedItemDto row in rejected) - { - if (row.MsgId == msgId && string.Equals(row.DialogId, dialog, StringComparison.Ordinal)) - { - return row.Text; - } - } - - return null; - } - - // Строка очереди сообщения (для отсева при ручной разметке «спам»). - // dialog: Id диалога. - // msgId: Id сообщения. - // ct: Токен отмены. - // Возвращает: Строка очереди либо null — сообщение уже обработано/не в очереди. - private async Task FindQueuedAsync(string dialog, long msgId, CancellationToken ct) - { - IReadOnlyList queue = await pipelineStore.ListAsync(status: null, MaxScan, ct); - foreach (QueueItemDto row in queue) - { - if (row.MsgId == msgId && string.Equals(row.DialogId, dialog, StringComparison.Ordinal)) - { - return row; - } - } - - return null; - } - - // Прогноз ML по тексту с защитой от сбоя (недоступный сервис — кандидат без мнения). - // text: Текст сообщения. - // ct: Токен отмены. - // Возвращает: Мнение ML либо null при сбое. - private async Task PredictSafelyAsync(string text, CancellationToken ct) - { - if (string.IsNullOrWhiteSpace(text)) - { - return null; - } - - try - { - MlPredictResultDto result = await mlClient.PredictAsync(text, ct); - return new MlCandidatePredictionDto(result.Take, result.Label, result.Scores); - } - catch (Exception) - { - return null; - } - } - - // Соответствует ли диалог фильтру канала (пустой фильтр — все диалоги). - // filter: Запрошенный канал (пусто — без фильтра). - // dialogId: Диалог сообщения. - // Возвращает: True — кандидат подходит. - private static bool MatchesDialog(string filter, string dialogId) => - filter.Length == 0 || string.Equals(filter, dialogId, StringComparison.Ordinal); - - // Кандидат из строки очереди (вердикт queued). - // row: Строка очереди. - // msgId: Id сообщения. - // Возвращает: Кандидат. - private static MlCandidateDto BuildQueued(QueueItemDto row, long msgId) => new() - { - Id = msgId, - DialogId = row.DialogId, - Text = Truncate(row.Text), - Time = row.MsgAtMs == 0 ? null : row.MsgAtMs, - Lead = false, - Verdict = VerdictQueued, - Stage = row.Status, - }; - - // Кандидат из записи отсева (вердикт rejected). - // row: Запись отсева. - // msgId: Id сообщения. - // Возвращает: Кандидат. - private static MlCandidateDto BuildRejected(RejectedItemDto row, long msgId) => new() - { - Id = msgId, - DialogId = row.DialogId, - Text = Truncate(row.Text), - Time = row.MsgAtMs == 0 ? null : row.MsgAtMs, - Lead = false, - Verdict = VerdictRejected, - Stage = row.Stage, - Reason = row.Reason, - }; - - // Кандидат из карточки (вердикт card). - // card: Карточка. - // msgId: Id сообщения. - // Возвращает: Кандидат. - private static MlCandidateDto BuildCard(CardDto card, long msgId) => new() - { - Id = msgId, - DialogId = card.SourceDialogId, - Text = Truncate(card.SourceMsg), - Time = card.ReceivedAtMs == 0 ? null : card.ReceivedAtMs, - Lead = true, - Verdict = VerdictCard, - Col = card.Col, - }; - - // Обрезает текст кандидата до TextPreviewLength символов. - // text: Исходный текст. - // Возвращает: Обрезанный текст. - private static string Truncate(string text) => - text.Length <= TextPreviewLength ? text : text[..TextPreviewLength]; -} +using Deal.Contracts.Integrations; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Cards.Application.Abstractions; +using Deal.Modules.Cards.Application.Dtos; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Registrars; + +namespace Deal.Modules.Pipeline.Application.Services; + +/// +/// Ручная проверка/разметка ML на сообщениях канала (§8 ML: «проверка на сообщении/канале»). +/// +/// +/// +/// Candidates: собирает реальные сообщения-кандидаты по каналу (dialogId) либо по всей выборке, если +/// канал не задан, из трёх существующих источников тенанта — очереди обработки (), +/// отсева () и карточек (), +/// объединяя по (dialogId, msgId): карточка «перекрывает» отсев, отсев — очередь. Каждый кандидат несёт +/// исходный текст и текущий вердикт; мнение ML добавляется прогнозом . +/// +/// +/// Apply: ручное решение пользователя — «спам», «в колонку», «пропустить» — применяется через +/// существующие сервисы/ядро: обучение ML — , перенос/корзина карточки — +/// (он сам учит ML, дублирования сигналов нет), отсев сообщения из очереди — +/// . Действие 1:1 с прототипом ml_routes.py L137–171 +/// (skip/spam/board:<id>), плюс отсев ещё не обработанного сообщения и защита от неизвестной доски. +/// +/// +public sealed class MlReviewService( + IPipelineStore pipelineStore, + ICardStore cardStore, + CardsService cards, + PipelineProcessingService processing, + IMlClient mlClient) +{ + /// + /// Минимум сообщений в выборке кандидатов (кламп запроса 1..60, как прототип). + /// + public const int MinCandidates = 1; + + /// + /// Максимум сообщений в выборке кандидатов (кламп запроса 1..60, как прототип). + /// + public const int MaxCandidates = 60; + + // Размер одного чтения из очереди/отсева при объединении кандидатов. + private const int MaxScan = 500; + + // Длина текста кандидата в ответе (ml_routes.py L129: text[:600]). + private const int TextPreviewLength = 600; + + /// + /// Вердикт кандидата: по сообщению уже есть карточка. + /// + public const string VerdictCard = "card"; + + /// + /// Вердикт кандидата: сообщение в отсеве. + /// + public const string VerdictRejected = "rejected"; + + /// + /// Вердикт кандидата: сообщение ждёт обработки в очереди. + /// + public const string VerdictQueued = "queued"; + + /// + /// Действие: пропустить без обучения (ml_routes.py L144–145). + /// + public const string ActionSkip = "skip"; + + /// + /// Действие: спам — учим ML и (если есть) карточку в корзину (ml_routes.py L150–156). + /// + public const string ActionSpam = "spam"; + + /// + /// Префикс действия «в колонку»: board:<id> (ml_routes.py L157). + /// + public const string ActionBoardPrefix = "board:"; + + /// + /// 400 apply: неизвестная доска-цель (ml_routes.py L159–160). + /// + public const string UnknownBoardDetail = "Неизвестная доска"; + + /// + /// 400 apply: неизвестное действие (ml_routes.py L169–170). + /// + public const string UnknownActionDetail = "Неизвестное действие"; + + // Причина отсева при ручной разметке «спам» ещё не обработанного сообщения. + private const string ManualSpamReason = "ручная разметка ML: спам"; + + // Этап отсева при ручной разметке «спам» (отсев решением ML). + private const string ManualSpamStage = "spam_ml"; + + // Источник решения при ручной разметке. + private const string ManualSource = "ml"; + + // Вес обучающего сигнала ручной разметки — действие пользователя (ml_client.py USER_WEIGHT 1.0). + private const double UserPushWeight = 1.0; + + /// + /// Отбирает сообщения-кандидаты для проверки ML по каналу и/или размеру выборки. + /// + /// Id канала/диалога; пусто — выборка по всем источникам тенанта. + /// Сколько последних сообщений вернуть (кламп 1.., дефолт вызывающего). + /// Токен отмены. + /// Кандидаты (свежие первыми): текст, текущий вердикт и мнение ML по каждому. + public async Task> CandidatesAsync(string? dialogId, int limit, CancellationToken ct) + { + int take = Math.Clamp(limit, MinCandidates, MaxCandidates); + string dialog = (dialogId ?? string.Empty).Trim(); + + IReadOnlyList queue = await pipelineStore.ListAsync(status: null, MaxScan, ct); + IReadOnlyList rejected = await pipelineStore.ListPageAsync(offset: 0, MaxScan, ct); + IReadOnlyList cardList = await cardStore.ListCardsAsync(new CardsQuery(null), ct); + + // Объединение по (dialogId, msgId): очередь → отсев → карточка (последняя перекрывает предыдущие). + var merged = new Dictionary<(string Dialog, long MsgId), MlCandidateDto>(); + foreach (QueueItemDto row in queue) + { + if (row.MsgId is not { } msgId || !MatchesDialog(dialog, row.DialogId)) + { + continue; + } + + merged[(row.DialogId, msgId)] = BuildQueued(row, msgId); + } + + foreach (RejectedItemDto row in rejected) + { + if (row.MsgId is not { } msgId || !MatchesDialog(dialog, row.DialogId)) + { + continue; + } + + merged[(row.DialogId, msgId)] = BuildRejected(row, msgId); + } + + foreach (CardDto card in cardList) + { + if (card.SourceMsgId is not { } msgId || !MatchesDialog(dialog, card.SourceDialogId)) + { + continue; + } + + merged[(card.SourceDialogId, msgId)] = BuildCard(card, msgId); + } + + List ordered = merged.Values + .OrderByDescending(candidate => candidate.Time ?? 0) + .Take(take) + .ToList(); + + var withPredictions = new List(ordered.Count); + foreach (MlCandidateDto candidate in ordered) + { + withPredictions.Add(candidate with { Pred = await PredictSafelyAsync(candidate.Text, ct) }); + } + + return withPredictions; + } + + /// + /// Применяет ручное решение по сообщению: обучение ML + перенос/корзина/отсев. + /// + /// Id канала/диалога сообщения. + /// Id исходного сообщения. + /// Действие: skip | spam | board:<id>. + /// Токен отмены. + /// Результат решения; null — исходное сообщение не найдено (404-семантика эндпоинта). + public async Task ApplyAsync(string dialogId, long msgId, string? action, CancellationToken ct) + { + string normalized = (action ?? string.Empty).Trim(); + string dialog = (dialogId ?? string.Empty).Trim(); + + CardDto? card = await cardStore.GetCardBySourceAsync(dialog, msgId, ct); + string? text = await FindTextAsync(dialog, msgId, card, ct); + if (string.IsNullOrWhiteSpace(text)) + { + return null; // 404: исходное сообщение не найдено + } + + if (normalized == ActionSkip) + { + return new MlApplyResult(Error: null, Ok: true, Learned: false, Moved: null, LeadId: null); + } + + if (normalized == ActionSpam) + { + return await ApplySpamAsync(dialog, msgId, card, text, ct); + } + + if (normalized.StartsWith(ActionBoardPrefix, StringComparison.Ordinal)) + { + string boardId = normalized[ActionBoardPrefix.Length..].Trim(); + return await ApplyBoardAsync(dialog, msgId, boardId, card, text, ct); + } + + return new MlApplyResult(UnknownActionDetail, Ok: false, Learned: false, Moved: null, LeadId: null); + } + + // Действие «спам»: карточку — в корзину (с обучением), сообщение из очереди — в отсев; иначе учим ML. + // dialog: Id диалога. + // msgId: Id сообщения. + // card: Карточка сообщения (null — сообщение не становилось карточкой). + // text: Текст сообщения. + // ct: Токен отмены. + // Возвращает: Результат решения. + private async Task ApplySpamAsync(string dialog, long msgId, CardDto? card, string text, CancellationToken ct) + { + if (card is not null) + { + // TrashCardAsync(teach:true) сам шлёт обучающий сигнал «спам» — второй сигнал не нужен. + CardDto? trashed = await cards.TrashCardAsync(card.Id, teach: true, ct); + return new MlApplyResult(null, Ok: true, Learned: true, Moved: "trash", LeadId: trashed?.Id ?? card.Id); + } + + await mlClient.PushAsync(text, MlLearningLabels.Spam, UserPushWeight, ct); + + // Сообщение ещё в очереди — отсеиваем его (решение пользователя), снимая строку. + QueueItemDto? queued = await FindQueuedAsync(dialog, msgId, ct); + if (queued is not null) + { + await processing.RejectAsync(new RejectRecord + { + DialogId = queued.DialogId, + MsgId = queued.MsgId, + Text = queued.Text, + ChannelName = queued.Channel.Name, + ChannelHandle = queued.Channel.Handle, + ChannelHue = queued.Channel.Hue, + MsgAtMs = queued.MsgAtMs, + Source = ManualSource, + Stage = ManualSpamStage, + Reason = ManualSpamReason, + Kw = string.Empty, + }, ct); + await pipelineStore.RemoveAsync(queued.Id, ct); + } + + return new MlApplyResult(null, Ok: true, Learned: true, Moved: null, LeadId: null); + } + + // Действие «в колонку»: карточку — переносим, уже в колонке — только учим; иначе учим ML. + // dialog: Id диалога. + // msgId: Id сообщения. + // boardId: Id колонки-цели (inbox или b_...). + // card: Карточка сообщения (null — сообщение не становилось карточкой). + // text: Текст сообщения. + // ct: Токен отмены. + // Возвращает: Результат решения. + private async Task ApplyBoardAsync( + string dialog, + long msgId, + string boardId, + CardDto? card, + string text, + CancellationToken ct) + { + if (boardId != CardIds.Inbox && await cardStore.GetContainerAsync(boardId, ct) is null) + { + return new MlApplyResult(UnknownBoardDetail, Ok: false, Learned: false, Moved: null, LeadId: null); + } + + if (card is null) + { + await mlClient.PushAsync(text, boardId, UserPushWeight, ct); + return new MlApplyResult(null, Ok: true, Learned: true, Moved: null, LeadId: null); + } + + if (card.Col == boardId) + { + // Повторная разметка карточки в той же колонке — только обучение (ml_routes.py L163–165). + await mlClient.PushAsync(text, boardId, UserPushWeight, ct); + return new MlApplyResult(null, Ok: true, Learned: true, Moved: null, LeadId: card.Id); + } + + // MoveDashboardCardAsync сам учит колонку (toCol ≠ inbox) — второй сигнал не нужен. + CardResultDto moved = await cards.MoveDashboardCardAsync(card.Id, boardId, ct); + if (moved.Error is not null) + { + return new MlApplyResult(moved.Error, Ok: false, Learned: false, Moved: null, LeadId: card.Id); + } + + return new MlApplyResult(null, Ok: true, Learned: true, Moved: boardId, LeadId: card.Id); + } + + // Текст исходного сообщения: source_msg карточки, иначе текст строки очереди/записи отсева. + // dialog: Id диалога. + // msgId: Id сообщения. + // card: Карточка сообщения (уже прочитана вызывающим). + // ct: Токен отмены. + // Возвращает: Непустой текст либо null, если сообщения нет ни в одном источнике. + private async Task FindTextAsync(string dialog, long msgId, CardDto? card, CancellationToken ct) + { + if (card is not null && !string.IsNullOrWhiteSpace(card.SourceMsg)) + { + return card.SourceMsg; + } + + QueueItemDto? queued = await FindQueuedAsync(dialog, msgId, ct); + if (queued is not null && !string.IsNullOrWhiteSpace(queued.Text)) + { + return queued.Text; + } + + IReadOnlyList rejected = await pipelineStore.ListPageAsync(offset: 0, MaxScan, ct); + foreach (RejectedItemDto row in rejected) + { + if (row.MsgId == msgId && string.Equals(row.DialogId, dialog, StringComparison.Ordinal)) + { + return row.Text; + } + } + + return null; + } + + // Строка очереди сообщения (для отсева при ручной разметке «спам»). + // dialog: Id диалога. + // msgId: Id сообщения. + // ct: Токен отмены. + // Возвращает: Строка очереди либо null — сообщение уже обработано/не в очереди. + private async Task FindQueuedAsync(string dialog, long msgId, CancellationToken ct) + { + IReadOnlyList queue = await pipelineStore.ListAsync(status: null, MaxScan, ct); + foreach (QueueItemDto row in queue) + { + if (row.MsgId == msgId && string.Equals(row.DialogId, dialog, StringComparison.Ordinal)) + { + return row; + } + } + + return null; + } + + // Прогноз ML по тексту с защитой от сбоя (недоступный сервис — кандидат без мнения). + // text: Текст сообщения. + // ct: Токен отмены. + // Возвращает: Мнение ML либо null при сбое. + private async Task PredictSafelyAsync(string text, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(text)) + { + return null; + } + + try + { + MlPredictResultDto result = await mlClient.PredictAsync(text, ct); + return new MlCandidatePredictionDto(result.Take, result.Label, result.Scores); + } + catch (Exception) + { + return null; + } + } + + // Соответствует ли диалог фильтру канала (пустой фильтр — все диалоги). + // filter: Запрошенный канал (пусто — без фильтра). + // dialogId: Диалог сообщения. + // Возвращает: True — кандидат подходит. + private static bool MatchesDialog(string filter, string dialogId) => + filter.Length == 0 || string.Equals(filter, dialogId, StringComparison.Ordinal); + + // Кандидат из строки очереди (вердикт queued). + // row: Строка очереди. + // msgId: Id сообщения. + // Возвращает: Кандидат. + private static MlCandidateDto BuildQueued(QueueItemDto row, long msgId) => new() + { + Id = msgId, + DialogId = row.DialogId, + Text = Truncate(row.Text), + Time = row.MsgAtMs == 0 ? null : row.MsgAtMs, + Lead = false, + Verdict = VerdictQueued, + Stage = row.Status, + }; + + // Кандидат из записи отсева (вердикт rejected). + // row: Запись отсева. + // msgId: Id сообщения. + // Возвращает: Кандидат. + private static MlCandidateDto BuildRejected(RejectedItemDto row, long msgId) => new() + { + Id = msgId, + DialogId = row.DialogId, + Text = Truncate(row.Text), + Time = row.MsgAtMs == 0 ? null : row.MsgAtMs, + Lead = false, + Verdict = VerdictRejected, + Stage = row.Stage, + Reason = row.Reason, + }; + + // Кандидат из карточки (вердикт card). + // card: Карточка. + // msgId: Id сообщения. + // Возвращает: Кандидат. + private static MlCandidateDto BuildCard(CardDto card, long msgId) => new() + { + Id = msgId, + DialogId = card.SourceDialogId, + Text = Truncate(card.SourceMsg), + Time = card.ReceivedAtMs == 0 ? null : card.ReceivedAtMs, + Lead = true, + Verdict = VerdictCard, + Col = card.Col, + }; + + // Обрезает текст кандидата до TextPreviewLength символов. + // text: Исходный текст. + // Возвращает: Обрезанный текст. + private static string Truncate(string text) => + text.Length <= TextPreviewLength ? text : text[..TextPreviewLength]; +} diff --git a/src/core/Deal.Modules.Pipeline/Application/PipelineCardWriter.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineCardWriter.cs similarity index 91% rename from src/core/Deal.Modules.Pipeline/Application/PipelineCardWriter.cs rename to src/core/Deal.Modules.Pipeline/Application/Services/PipelineCardWriter.cs index af3a848..85b40b9 100644 --- a/src/core/Deal.Modules.Pipeline/Application/PipelineCardWriter.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineCardWriter.cs @@ -1,9 +1,14 @@ using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Registrars; -namespace Deal.Modules.Pipeline.Application; +namespace Deal.Modules.Pipeline.Application.Services; /// /// Создание карточки пайплайна через публичный интерфейс Kanban (план Task 7 L400–402, Ruling 3/4; diff --git a/src/core/Deal.Modules.Pipeline/Application/PipelineIngestService.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineIngestService.cs similarity index 90% rename from src/core/Deal.Modules.Pipeline/Application/PipelineIngestService.cs rename to src/core/Deal.Modules.Pipeline/Application/Services/PipelineIngestService.cs index c8da85a..4b463d3 100644 --- a/src/core/Deal.Modules.Pipeline/Application/PipelineIngestService.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineIngestService.cs @@ -1,8 +1,14 @@ -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Parse; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Registrars; -namespace Deal.Modules.Pipeline.Application; +namespace Deal.Modules.Pipeline.Application.Services; /// /// Приём входящих сообщений пайплайна — постановка сырого сообщения в очередь QueueItems (Ruling 2, pipeline.py enqueue L53–85). diff --git a/src/core/Deal.Modules.Pipeline/Application/PipelineProcessingService.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineProcessingService.cs similarity index 98% rename from src/core/Deal.Modules.Pipeline/Application/PipelineProcessingService.cs rename to src/core/Deal.Modules.Pipeline/Application/Services/PipelineProcessingService.cs index 7a7298d..558b9c1 100644 --- a/src/core/Deal.Modules.Pipeline/Application/PipelineProcessingService.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineProcessingService.cs @@ -1,9 +1,15 @@ using Deal.Contracts.Integrations; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Parse; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Registrars; -namespace Deal.Modules.Pipeline.Application; +namespace Deal.Modules.Pipeline.Application.Services; /// /// Мониторинг и обслуживание пайплайна — вкладка «Обработка»: очередь, отсев, возврат, очистки, счётчики (processing.py L66–320, Rulings 8/10). diff --git a/src/core/Deal.Modules.Pipeline/Application/PipelineWorkerService.Checks.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Checks.cs similarity index 93% rename from src/core/Deal.Modules.Pipeline/Application/PipelineWorkerService.Checks.cs rename to src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Checks.cs index 6e0dad9..fe217a7 100644 --- a/src/core/Deal.Modules.Pipeline/Application/PipelineWorkerService.Checks.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Checks.cs @@ -2,15 +2,22 @@ using System.Globalization; using System.Text.Json; using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.ColumnRules; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Kanban.Application.ColumnRules; using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Registrars; -namespace Deal.Modules.Pipeline.Application; +namespace Deal.Modules.Pipeline.Application.Services; /// /// Проверки воркера — partial-часть (C32: выделено из общего файла, diff --git a/src/core/Deal.Modules.Pipeline/Application/PipelineWorkerService.Decisions.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Decisions.cs similarity index 85% rename from src/core/Deal.Modules.Pipeline/Application/PipelineWorkerService.Decisions.cs rename to src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Decisions.cs index 62ff36c..84f50c7 100644 --- a/src/core/Deal.Modules.Pipeline/Application/PipelineWorkerService.Decisions.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Decisions.cs @@ -2,15 +2,22 @@ using System.Globalization; using System.Text.Json; using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.ColumnRules; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Kanban.Application.ColumnRules; using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Registrars; -namespace Deal.Modules.Pipeline.Application; +namespace Deal.Modules.Pipeline.Application.Services; /// /// KV-счётчики решений — partial-часть (C32: выделено из общего diff --git a/src/core/Deal.Modules.Pipeline/Application/PipelineWorkerService.Learning.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Learning.cs similarity index 91% rename from src/core/Deal.Modules.Pipeline/Application/PipelineWorkerService.Learning.cs rename to src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Learning.cs index f856b80..f839fd4 100644 --- a/src/core/Deal.Modules.Pipeline/Application/PipelineWorkerService.Learning.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Learning.cs @@ -2,11 +2,16 @@ using System.Globalization; using System.Text.Json; using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Registrars; -namespace Deal.Modules.Pipeline.Application; +namespace Deal.Modules.Pipeline.Application.Services; /// /// Карточка и обучение ML — partial-часть (C32: выделено из общего diff --git a/src/core/Deal.Modules.Pipeline/Application/PipelineWorkerService.Pump.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Pump.cs similarity index 97% rename from src/core/Deal.Modules.Pipeline/Application/PipelineWorkerService.Pump.cs rename to src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Pump.cs index 6b9755d..9c42fcf 100644 --- a/src/core/Deal.Modules.Pipeline/Application/PipelineWorkerService.Pump.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Pump.cs @@ -2,15 +2,22 @@ using System.Globalization; using System.Text.Json; using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.ColumnRules; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Kanban.Application.ColumnRules; using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Registrars; -namespace Deal.Modules.Pipeline.Application; +namespace Deal.Modules.Pipeline.Application.Services; /// /// Проход pump — partial-часть (C32: выделено из общего файла, diff --git a/src/core/Deal.Modules.Pipeline/Application/PipelineWorkerService.Rejections.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Rejections.cs similarity index 87% rename from src/core/Deal.Modules.Pipeline/Application/PipelineWorkerService.Rejections.cs rename to src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Rejections.cs index 57a6e34..b7af9f6 100644 --- a/src/core/Deal.Modules.Pipeline/Application/PipelineWorkerService.Rejections.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Rejections.cs @@ -2,15 +2,22 @@ using System.Globalization; using System.Text.Json; using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.ColumnRules; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Kanban.Application.ColumnRules; using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Registrars; -namespace Deal.Modules.Pipeline.Application; +namespace Deal.Modules.Pipeline.Application.Services; /// /// Отсев и удаление строк — partial-часть (C32: выделено из общего diff --git a/src/core/Deal.Modules.Pipeline/Application/PipelineWorkerService.Settings.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Settings.cs similarity index 92% rename from src/core/Deal.Modules.Pipeline/Application/PipelineWorkerService.Settings.cs rename to src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Settings.cs index 10f82bc..069f25b 100644 --- a/src/core/Deal.Modules.Pipeline/Application/PipelineWorkerService.Settings.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.Settings.cs @@ -2,15 +2,22 @@ using System.Globalization; using System.Text.Json; using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.ColumnRules; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Kanban.Application.ColumnRules; using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Registrars; -namespace Deal.Modules.Pipeline.Application; +namespace Deal.Modules.Pipeline.Application.Services; /// /// Снимок настроек прохода — partial-часть (C32: выделено из общего diff --git a/src/core/Deal.Modules.Pipeline/Application/PipelineWorkerService.State.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.State.cs similarity index 86% rename from src/core/Deal.Modules.Pipeline/Application/PipelineWorkerService.State.cs rename to src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.State.cs index 3eda61c..b2ef58a 100644 --- a/src/core/Deal.Modules.Pipeline/Application/PipelineWorkerService.State.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.State.cs @@ -2,15 +2,22 @@ using System.Globalization; using System.Text.Json; using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.ColumnRules; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Kanban.Application.ColumnRules; using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Registrars; -namespace Deal.Modules.Pipeline.Application; +namespace Deal.Modules.Pipeline.Application.Services; /// /// Накопители результата pump — partial-часть (C32: выделено из diff --git a/src/core/Deal.Modules.Pipeline/Application/PipelineWorkerService.cs b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.cs similarity index 95% rename from src/core/Deal.Modules.Pipeline/Application/PipelineWorkerService.cs rename to src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.cs index 254c703..d921c00 100644 --- a/src/core/Deal.Modules.Pipeline/Application/PipelineWorkerService.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/PipelineWorkerService.cs @@ -2,15 +2,22 @@ using System.Globalization; using System.Text.Json; using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.ColumnRules; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Kanban.Application.ColumnRules; using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Registrars; -namespace Deal.Modules.Pipeline.Application; +namespace Deal.Modules.Pipeline.Application.Services; /// /// Воркер разбора очереди входящих — один проход pump (Ruling 8; прототип _pump_unlocked L920–1183, порядок строго 1:1). diff --git a/src/core/Deal.Modules.Pipeline/Application/ReclassifyGate.cs b/src/core/Deal.Modules.Pipeline/Application/Services/ReclassifyGate.cs similarity index 85% rename from src/core/Deal.Modules.Pipeline/Application/ReclassifyGate.cs rename to src/core/Deal.Modules.Pipeline/Application/Services/ReclassifyGate.cs index 7325cdd..0ec57df 100644 --- a/src/core/Deal.Modules.Pipeline/Application/ReclassifyGate.cs +++ b/src/core/Deal.Modules.Pipeline/Application/Services/ReclassifyGate.cs @@ -1,4 +1,8 @@ -namespace Deal.Modules.Pipeline.Application; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; + +namespace Deal.Modules.Pipeline.Application.Services; /// /// Single-flight-замок ручной переклассификации: одна переклассификация за раз (как фоновая задача прототипа). diff --git a/src/core/Deal.Modules.Settings/Application/IAiConnectionChecker.cs b/src/core/Deal.Modules.Settings/Application/Abstractions/IAiConnectionChecker.cs similarity index 90% rename from src/core/Deal.Modules.Settings/Application/IAiConnectionChecker.cs rename to src/core/Deal.Modules.Settings/Application/Abstractions/IAiConnectionChecker.cs index ec737a5..2a504dd 100644 --- a/src/core/Deal.Modules.Settings/Application/IAiConnectionChecker.cs +++ b/src/core/Deal.Modules.Settings/Application/Abstractions/IAiConnectionChecker.cs @@ -1,6 +1,8 @@ using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; -namespace Deal.Modules.Settings.Application; +namespace Deal.Modules.Settings.Application.Abstractions; /// /// Порт проверки подключения к выбранному AI-провайдеру (Ruling 4/7). diff --git a/src/core/Deal.Modules.Settings/Application/IGlobalSettingsStore.cs b/src/core/Deal.Modules.Settings/Application/Abstractions/IGlobalSettingsStore.cs similarity index 90% rename from src/core/Deal.Modules.Settings/Application/IGlobalSettingsStore.cs rename to src/core/Deal.Modules.Settings/Application/Abstractions/IGlobalSettingsStore.cs index f27627f..d3a016b 100644 --- a/src/core/Deal.Modules.Settings/Application/IGlobalSettingsStore.cs +++ b/src/core/Deal.Modules.Settings/Application/Abstractions/IGlobalSettingsStore.cs @@ -1,6 +1,8 @@ using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; -namespace Deal.Modules.Settings.Application; +namespace Deal.Modules.Settings.Application.Abstractions; /// /// Порт KV-хранилища глобальных (системных) настроек оператора: таблица public.global_settings. diff --git a/src/core/Deal.Modules.Settings/Application/IRatesChangedListener.cs b/src/core/Deal.Modules.Settings/Application/Abstractions/IRatesChangedListener.cs similarity index 89% rename from src/core/Deal.Modules.Settings/Application/IRatesChangedListener.cs rename to src/core/Deal.Modules.Settings/Application/Abstractions/IRatesChangedListener.cs index ba48f88..7d74bc5 100644 --- a/src/core/Deal.Modules.Settings/Application/IRatesChangedListener.cs +++ b/src/core/Deal.Modules.Settings/Application/Abstractions/IRatesChangedListener.cs @@ -1,4 +1,8 @@ -namespace Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; + +namespace Deal.Modules.Settings.Application.Abstractions; /// /// Порт модуля Settings: уведомление об изменении курсов/настроек конверсии (Ruling 7, план Task 12). diff --git a/src/core/Deal.Modules.Settings/Application/IRatesSource.cs b/src/core/Deal.Modules.Settings/Application/Abstractions/IRatesSource.cs similarity index 85% rename from src/core/Deal.Modules.Settings/Application/IRatesSource.cs rename to src/core/Deal.Modules.Settings/Application/Abstractions/IRatesSource.cs index 5c70db7..a004282 100644 --- a/src/core/Deal.Modules.Settings/Application/IRatesSource.cs +++ b/src/core/Deal.Modules.Settings/Application/Abstractions/IRatesSource.cs @@ -1,4 +1,8 @@ -namespace Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; + +namespace Deal.Modules.Settings.Application.Abstractions; /// /// Порт источника курсов валют к рублю (Ruling 6, Task 8). diff --git a/src/core/Deal.Modules.Settings/Application/ISecretCipher.cs b/src/core/Deal.Modules.Settings/Application/Abstractions/ISecretCipher.cs similarity index 89% rename from src/core/Deal.Modules.Settings/Application/ISecretCipher.cs rename to src/core/Deal.Modules.Settings/Application/Abstractions/ISecretCipher.cs index e340d67..30d79f4 100644 --- a/src/core/Deal.Modules.Settings/Application/ISecretCipher.cs +++ b/src/core/Deal.Modules.Settings/Application/Abstractions/ISecretCipher.cs @@ -1,4 +1,8 @@ -namespace Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; + +namespace Deal.Modules.Settings.Application.Abstractions; /// /// Порт симметричного шифрования секретов тенанта (ключи AI/Telegram), Ruling 2. diff --git a/src/core/Deal.Modules.Settings/Application/ISettingsStore.cs b/src/core/Deal.Modules.Settings/Application/Abstractions/ISettingsStore.cs similarity index 94% rename from src/core/Deal.Modules.Settings/Application/ISettingsStore.cs rename to src/core/Deal.Modules.Settings/Application/Abstractions/ISettingsStore.cs index 617a731..783b49f 100644 --- a/src/core/Deal.Modules.Settings/Application/ISettingsStore.cs +++ b/src/core/Deal.Modules.Settings/Application/Abstractions/ISettingsStore.cs @@ -1,6 +1,8 @@ using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; -namespace Deal.Modules.Settings.Application; +namespace Deal.Modules.Settings.Application.Abstractions; /// /// Порт KV-хранилища настроек тенанта (таблица settings), Ruling 1. diff --git a/src/core/Deal.Modules.Settings/Application/AiProviderDefinition.cs b/src/core/Deal.Modules.Settings/Application/Models/AiProviderDefinition.cs similarity index 86% rename from src/core/Deal.Modules.Settings/Application/AiProviderDefinition.cs rename to src/core/Deal.Modules.Settings/Application/Models/AiProviderDefinition.cs index 26ab308..1a1929e 100644 --- a/src/core/Deal.Modules.Settings/Application/AiProviderDefinition.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/AiProviderDefinition.cs @@ -1,4 +1,8 @@ -namespace Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; + +namespace Deal.Modules.Settings.Application.Models; /// /// Описание AI-провайдера — зеркало constants.AI_PROVIDERS (constants.py L170–186), Ruling 3. diff --git a/src/core/Deal.Modules.Settings/Application/AiProviders.cs b/src/core/Deal.Modules.Settings/Application/Models/AiProviders.cs similarity index 93% rename from src/core/Deal.Modules.Settings/Application/AiProviders.cs rename to src/core/Deal.Modules.Settings/Application/Models/AiProviders.cs index 7f0deb6..d0e3433 100644 --- a/src/core/Deal.Modules.Settings/Application/AiProviders.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/AiProviders.cs @@ -1,4 +1,8 @@ -namespace Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; + +namespace Deal.Modules.Settings.Application.Models; /// /// Статический список AI-провайдеров (зеркало constants.AI_PROVIDERS, constants.py L170–186). diff --git a/src/core/Deal.Modules.Settings/Application/DefaultPrompts.cs b/src/core/Deal.Modules.Settings/Application/Models/DefaultPrompts.cs similarity index 98% rename from src/core/Deal.Modules.Settings/Application/DefaultPrompts.cs rename to src/core/Deal.Modules.Settings/Application/Models/DefaultPrompts.cs index 872354d..63ab498 100644 --- a/src/core/Deal.Modules.Settings/Application/DefaultPrompts.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/DefaultPrompts.cs @@ -1,4 +1,8 @@ -namespace Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; + +namespace Deal.Modules.Settings.Application.Models; /// /// Дефолтные тексты ИИ-промптов (копия из src/frontend/src/data.js L94–141). diff --git a/src/core/Deal.Modules.Settings/Application/GlobalSettingsKeys.cs b/src/core/Deal.Modules.Settings/Application/Models/GlobalSettingsKeys.cs similarity index 80% rename from src/core/Deal.Modules.Settings/Application/GlobalSettingsKeys.cs rename to src/core/Deal.Modules.Settings/Application/Models/GlobalSettingsKeys.cs index 938a426..eaf82a5 100644 --- a/src/core/Deal.Modules.Settings/Application/GlobalSettingsKeys.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/GlobalSettingsKeys.cs @@ -1,4 +1,8 @@ -namespace Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; + +namespace Deal.Modules.Settings.Application.Models; /// /// Каталог ключей глобальных (системных) настроек оператора (таблица public.global_settings). diff --git a/src/core/Deal.Modules.Settings/Application/IncomingRules.cs b/src/core/Deal.Modules.Settings/Application/Models/IncomingRules.cs similarity index 98% rename from src/core/Deal.Modules.Settings/Application/IncomingRules.cs rename to src/core/Deal.Modules.Settings/Application/Models/IncomingRules.cs index d9ef7a0..bcbf22e 100644 --- a/src/core/Deal.Modules.Settings/Application/IncomingRules.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/IncomingRules.cs @@ -1,6 +1,9 @@ using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; -namespace Deal.Modules.Settings.Application; +namespace Deal.Modules.Settings.Application.Models; /// /// Этап-1 правила фильтра входящих — чистая реализация stage1_plain (pipeline.py L94–124). diff --git a/src/core/Deal.Modules.Settings/Application/Models/IncomingRulesResult.cs b/src/core/Deal.Modules.Settings/Application/Models/IncomingRulesResult.cs index 4290f30..039cecd 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/IncomingRulesResult.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/IncomingRulesResult.cs @@ -14,7 +14,7 @@ namespace Deal.Modules.Settings.Application.Models; /// /// True — текст прошёл этап 1 (дальше этап 2/ИИ); False — отсечён правилом. /// Причина для UI (фиксированные строки прототипа) или null на проходе. -/// Номер этапа пайплайна — всегда 1 (константа ). +/// Номер этапа пайплайна — всегда 1 (константа ). /// Какое правило сработало: length|stop|resume|type; на проходе — "" (pipeline.py L97–99). /// Конкретная стоп-фраза/маркер резюме (для мониторинга отсева); иначе "". public sealed record IncomingRulesResult( diff --git a/src/core/Deal.Modules.Settings/Application/Models/ProviderPublicDto.cs b/src/core/Deal.Modules.Settings/Application/Models/ProviderPublicDto.cs index f3d9eff..30bd77e 100644 --- a/src/core/Deal.Modules.Settings/Application/Models/ProviderPublicDto.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/ProviderPublicDto.cs @@ -4,7 +4,7 @@ namespace Deal.Modules.Settings.Application.Models; /// Провайдер ИИ в public-снимке настроек (поле providers, Ruling 3, api-map §4.6). /// /// -/// Статический список из без внутреннего +/// Статический список из без внутреннего /// поля api_style. Сериализуется в camelCase: id/name/base/local/models. /// /// Идентификатор провайдера. diff --git a/src/core/Deal.Modules.Settings/Application/SettingKind.cs b/src/core/Deal.Modules.Settings/Application/Models/SettingKind.cs similarity index 90% rename from src/core/Deal.Modules.Settings/Application/SettingKind.cs rename to src/core/Deal.Modules.Settings/Application/Models/SettingKind.cs index 0f7af5e..66ee22c 100644 --- a/src/core/Deal.Modules.Settings/Application/SettingKind.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/SettingKind.cs @@ -1,4 +1,8 @@ -namespace Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; + +namespace Deal.Modules.Settings.Application.Models; /// /// Категория ключа настроек тенанта: определяет тип значения и обработку в PATCH (Ruling 1). diff --git a/src/core/Deal.Modules.Settings/Application/SettingsDefaults.cs b/src/core/Deal.Modules.Settings/Application/Models/SettingsDefaults.cs similarity index 98% rename from src/core/Deal.Modules.Settings/Application/SettingsDefaults.cs rename to src/core/Deal.Modules.Settings/Application/Models/SettingsDefaults.cs index cd539a4..ee3c091 100644 --- a/src/core/Deal.Modules.Settings/Application/SettingsDefaults.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/SettingsDefaults.cs @@ -1,6 +1,9 @@ using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; -namespace Deal.Modules.Settings.Application; +namespace Deal.Modules.Settings.Application.Models; /// /// Дефолтные значения настроек тенанта (Ruling 1: снимок = дефолты, перекрытые сохранёнными). diff --git a/src/core/Deal.Modules.Settings/Application/SettingsKeys.cs b/src/core/Deal.Modules.Settings/Application/Models/SettingsKeys.cs similarity index 98% rename from src/core/Deal.Modules.Settings/Application/SettingsKeys.cs rename to src/core/Deal.Modules.Settings/Application/Models/SettingsKeys.cs index ab6f853..a4079c7 100644 --- a/src/core/Deal.Modules.Settings/Application/SettingsKeys.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/SettingsKeys.cs @@ -1,4 +1,8 @@ -namespace Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; + +namespace Deal.Modules.Settings.Application.Models; /// /// Каталог ключей настроек тенанта (Ruling 1, api-map §4.6). diff --git a/src/core/Deal.Modules.Settings/Application/TenantSettingsSnapshot.cs b/src/core/Deal.Modules.Settings/Application/Models/TenantSettingsSnapshot.cs similarity index 98% rename from src/core/Deal.Modules.Settings/Application/TenantSettingsSnapshot.cs rename to src/core/Deal.Modules.Settings/Application/Models/TenantSettingsSnapshot.cs index f010129..20916cf 100644 --- a/src/core/Deal.Modules.Settings/Application/TenantSettingsSnapshot.cs +++ b/src/core/Deal.Modules.Settings/Application/Models/TenantSettingsSnapshot.cs @@ -1,8 +1,11 @@ using System.Globalization; using System.Text.Json; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; -namespace Deal.Modules.Settings.Application; +namespace Deal.Modules.Settings.Application.Models; /// /// Типизированный снимок настроек тенанта (C30: один читатель вместо копий GetAsync+Parse+дефолт). diff --git a/src/core/Deal.Modules.Settings/Application/SettingsModuleRegistrar.cs b/src/core/Deal.Modules.Settings/Application/Registrars/SettingsModuleRegistrar.cs similarity index 89% rename from src/core/Deal.Modules.Settings/Application/SettingsModuleRegistrar.cs rename to src/core/Deal.Modules.Settings/Application/Registrars/SettingsModuleRegistrar.cs index 7fcf157..757e3c5 100644 --- a/src/core/Deal.Modules.Settings/Application/SettingsModuleRegistrar.cs +++ b/src/core/Deal.Modules.Settings/Application/Registrars/SettingsModuleRegistrar.cs @@ -1,6 +1,9 @@ using Microsoft.Extensions.DependencyInjection; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Services; -namespace Deal.Modules.Settings.Application; +namespace Deal.Modules.Settings.Application.Registrars; /// /// DI-регистрация модуля Settings. Паттерн «port & adapter» (Ruling 1). diff --git a/src/core/Deal.Modules.Settings/Application/MockRates.cs b/src/core/Deal.Modules.Settings/Application/Services/MockRates.cs similarity index 85% rename from src/core/Deal.Modules.Settings/Application/MockRates.cs rename to src/core/Deal.Modules.Settings/Application/Services/MockRates.cs index 3b229cd..b6d1a5a 100644 --- a/src/core/Deal.Modules.Settings/Application/MockRates.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/MockRates.cs @@ -1,4 +1,8 @@ -namespace Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; + +namespace Deal.Modules.Settings.Application.Services; /// /// Константы курсов валют: мок-курсы и интервал обновления кэша (Ruling 6). diff --git a/src/core/Deal.Modules.Settings/Application/PromptFiller.cs b/src/core/Deal.Modules.Settings/Application/Services/PromptFiller.cs similarity index 94% rename from src/core/Deal.Modules.Settings/Application/PromptFiller.cs rename to src/core/Deal.Modules.Settings/Application/Services/PromptFiller.cs index f118c46..667af18 100644 --- a/src/core/Deal.Modules.Settings/Application/PromptFiller.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/PromptFiller.cs @@ -1,4 +1,8 @@ -namespace Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; + +namespace Deal.Modules.Settings.Application.Services; /// /// Подстановка плейсхолдеров {domain}/{keywords} в текст промпта (чистая функция). diff --git a/src/core/Deal.Modules.Settings/Application/RatesService.cs b/src/core/Deal.Modules.Settings/Application/Services/RatesService.cs similarity index 98% rename from src/core/Deal.Modules.Settings/Application/RatesService.cs rename to src/core/Deal.Modules.Settings/Application/Services/RatesService.cs index 78eaced..37e42be 100644 --- a/src/core/Deal.Modules.Settings/Application/RatesService.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/RatesService.cs @@ -1,7 +1,9 @@ using System.Text.Json; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Registrars; -namespace Deal.Modules.Settings.Application; +namespace Deal.Modules.Settings.Application.Services; /// /// Курсы валют: кэш в tenant-настройке ratesCache, источник по rateSource (Ruling 6, Task 8). diff --git a/src/core/Deal.Modules.Settings/Application/SettingsService.PatchMyPrompts.cs b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchMyPrompts.cs similarity index 95% rename from src/core/Deal.Modules.Settings/Application/SettingsService.PatchMyPrompts.cs rename to src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchMyPrompts.cs index 9055431..a386f7b 100644 --- a/src/core/Deal.Modules.Settings/Application/SettingsService.PatchMyPrompts.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchMyPrompts.cs @@ -1,8 +1,10 @@ using System.Security.Cryptography; using System.Text.Json; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Registrars; -namespace Deal.Modules.Settings.Application; +namespace Deal.Modules.Settings.Application.Services; // Часть SettingsService: PATCH-ключ myPrompts — чистка и сериализация «Моих промптов» (SerializeCleanMyPrompts // и чтение полей элемента: ReadTrimmedField/ReadPromptId). colState — passthrough в главном ApplyPatchAsync. diff --git a/src/core/Deal.Modules.Settings/Application/SettingsService.PatchScalarKeys.cs b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchScalarKeys.cs similarity index 97% rename from src/core/Deal.Modules.Settings/Application/SettingsService.PatchScalarKeys.cs rename to src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchScalarKeys.cs index 9f748ce..6d65a8a 100644 --- a/src/core/Deal.Modules.Settings/Application/SettingsService.PatchScalarKeys.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchScalarKeys.cs @@ -1,7 +1,9 @@ using System.Text.Json; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Registrars; -namespace Deal.Modules.Settings.Application; +namespace Deal.Modules.Settings.Application.Services; // Часть SettingsService: применение скалярных PATCH-ключей — пары пауз авто-вступлений (PrepareDelayClamps), // int/string-ключи и списки строк (ApplyIntKey/ApplyStringKey/SerializeStringList). diff --git a/src/core/Deal.Modules.Settings/Application/SettingsService.PatchSecrets.cs b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchSecrets.cs similarity index 97% rename from src/core/Deal.Modules.Settings/Application/SettingsService.PatchSecrets.cs rename to src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchSecrets.cs index 8a48ff9..45ca9e6 100644 --- a/src/core/Deal.Modules.Settings/Application/SettingsService.PatchSecrets.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PatchSecrets.cs @@ -1,7 +1,9 @@ using System.Text.Json; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Registrars; -namespace Deal.Modules.Settings.Application; +namespace Deal.Modules.Settings.Application.Services; // Часть SettingsService: PATCH-ключ секрета aiConfigs (ApplyAiConfigsKey, SSRF-гейт baseUrl, шифрование // apiKey), включая удаление переопределения, вернувшегося к дефолту (EqualsDefault). diff --git a/src/core/Deal.Modules.Settings/Application/SettingsService.PublicForms.cs b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PublicForms.cs similarity index 91% rename from src/core/Deal.Modules.Settings/Application/SettingsService.PublicForms.cs rename to src/core/Deal.Modules.Settings/Application/Services/SettingsService.PublicForms.cs index df388c7..2596e0e 100644 --- a/src/core/Deal.Modules.Settings/Application/SettingsService.PublicForms.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.PublicForms.cs @@ -1,7 +1,9 @@ using System.Text.Json; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Registrars; -namespace Deal.Modules.Settings.Application; +namespace Deal.Modules.Settings.Application.Services; // Часть SettingsService: публичные формы секретов — маски и флаги наружу (Ruling 3), ToPublic/MaskSecret. public sealed partial class SettingsService diff --git a/src/core/Deal.Modules.Settings/Application/SettingsService.ReadMerge.cs b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.ReadMerge.cs similarity index 98% rename from src/core/Deal.Modules.Settings/Application/SettingsService.ReadMerge.cs rename to src/core/Deal.Modules.Settings/Application/Services/SettingsService.ReadMerge.cs index 9798339..f9e6a85 100644 --- a/src/core/Deal.Modules.Settings/Application/SettingsService.ReadMerge.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.ReadMerge.cs @@ -1,8 +1,10 @@ using System.Globalization; using System.Text.Json; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Registrars; -namespace Deal.Modules.Settings.Application; +namespace Deal.Modules.Settings.Application.Services; // Часть SettingsService: общие хелперы чтения/слияния снимка (Ruling 1) — LoadStoredOverridesAsync, // TryRead* и Merge* по категориям ключей (канон мягкого чтения для GET /api/settings). diff --git a/src/core/Deal.Modules.Settings/Application/SettingsService.cs b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.cs similarity index 99% rename from src/core/Deal.Modules.Settings/Application/SettingsService.cs rename to src/core/Deal.Modules.Settings/Application/Services/SettingsService.cs index 26aa6d1..9be3122 100644 --- a/src/core/Deal.Modules.Settings/Application/SettingsService.cs +++ b/src/core/Deal.Modules.Settings/Application/Services/SettingsService.cs @@ -2,8 +2,10 @@ using System.Globalization; using System.Security.Cryptography; using System.Text.Json; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Registrars; -namespace Deal.Modules.Settings.Application; +namespace Deal.Modules.Settings.Application.Services; /// /// Сервис настроек тенанта: public-снимок (GET) и частичное обновление (PATCH-семантика 1:1). diff --git a/src/core/Deal.Modules.Telegram/Application/DialogsService.cs b/src/core/Deal.Modules.Telegram/Application/DialogsService.cs index 04deabf..3a89816 100644 --- a/src/core/Deal.Modules.Telegram/Application/DialogsService.cs +++ b/src/core/Deal.Modules.Telegram/Application/DialogsService.cs @@ -1,6 +1,9 @@ using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; using Deal.Modules.Telegram.Application.Models; using Microsoft.Extensions.Logging; diff --git a/src/core/Deal.Modules.Telegram/Application/TelegramModuleRegistrar.cs b/src/core/Deal.Modules.Telegram/Application/TelegramModuleRegistrar.cs index 8c8a8eb..6470b1a 100644 --- a/src/core/Deal.Modules.Telegram/Application/TelegramModuleRegistrar.cs +++ b/src/core/Deal.Modules.Telegram/Application/TelegramModuleRegistrar.cs @@ -1,5 +1,8 @@ using Deal.Contracts.Integrations; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; using Microsoft.Extensions.DependencyInjection; namespace Deal.Modules.Telegram.Application; diff --git a/src/core/Deal.Modules.Tenants/Application/IAuditLogStore.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuditLogStore.cs similarity index 94% rename from src/core/Deal.Modules.Tenants/Application/IAuditLogStore.cs rename to src/core/Deal.Modules.Tenants/Application/Abstractions/IAuditLogStore.cs index 459a23f..0b6f6e1 100644 --- a/src/core/Deal.Modules.Tenants/Application/IAuditLogStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuditLogStore.cs @@ -1,6 +1,9 @@ using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; -namespace Deal.Modules.Tenants.Application; +namespace Deal.Modules.Tenants.Application.Abstractions; /// /// Порт хранилища аудита: append-only запись и чтение ленты (реализация — EF-адаптер AuditLogStore в Infrastructure). diff --git a/src/core/Deal.Modules.Tenants/Application/IAuthStore.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuthStore.cs similarity index 95% rename from src/core/Deal.Modules.Tenants/Application/IAuthStore.cs rename to src/core/Deal.Modules.Tenants/Application/Abstractions/IAuthStore.cs index ec87132..146dccb 100644 --- a/src/core/Deal.Modules.Tenants/Application/IAuthStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/IAuthStore.cs @@ -1,6 +1,9 @@ using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; -namespace Deal.Modules.Tenants.Application; +namespace Deal.Modules.Tenants.Application.Abstractions; /// /// Порт хранилища аутентификации: пользователи и сессии (реализация — EF-адаптер в Infrastructure). diff --git a/src/core/Deal.Modules.Tenants/Application/IInviteStore.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/IInviteStore.cs similarity index 95% rename from src/core/Deal.Modules.Tenants/Application/IInviteStore.cs rename to src/core/Deal.Modules.Tenants/Application/Abstractions/IInviteStore.cs index 911da03..19fc595 100644 --- a/src/core/Deal.Modules.Tenants/Application/IInviteStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/IInviteStore.cs @@ -1,6 +1,9 @@ using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; -namespace Deal.Modules.Tenants.Application; +namespace Deal.Modules.Tenants.Application.Abstractions; /// /// Порт хранилища приглашений: таблица public.invites (реализация — EF-адаптер InviteStore в Infrastructure). diff --git a/src/core/Deal.Modules.Tenants/Application/IOperatorAuthStore.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/IOperatorAuthStore.cs similarity index 93% rename from src/core/Deal.Modules.Tenants/Application/IOperatorAuthStore.cs rename to src/core/Deal.Modules.Tenants/Application/Abstractions/IOperatorAuthStore.cs index 87a842e..118fd67 100644 --- a/src/core/Deal.Modules.Tenants/Application/IOperatorAuthStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/IOperatorAuthStore.cs @@ -1,6 +1,9 @@ using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; -namespace Deal.Modules.Tenants.Application; +namespace Deal.Modules.Tenants.Application.Abstractions; /// /// Порт хранилища аутентификации оператора: учётные записи и сессии (реализация — EF-адаптер в Infrastructure). diff --git a/src/core/Deal.Modules.Tenants/Application/IPasswordHasher.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/IPasswordHasher.cs similarity index 82% rename from src/core/Deal.Modules.Tenants/Application/IPasswordHasher.cs rename to src/core/Deal.Modules.Tenants/Application/Abstractions/IPasswordHasher.cs index 077ce07..65d4336 100644 --- a/src/core/Deal.Modules.Tenants/Application/IPasswordHasher.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/IPasswordHasher.cs @@ -1,4 +1,9 @@ -namespace Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Modules.Tenants.Application.Abstractions; /// /// Порт хэширования паролей (проверка учётных данных, смена пароля). diff --git a/src/core/Deal.Modules.Tenants/Application/IRateLimitCounterStore.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/IRateLimitCounterStore.cs similarity index 92% rename from src/core/Deal.Modules.Tenants/Application/IRateLimitCounterStore.cs rename to src/core/Deal.Modules.Tenants/Application/Abstractions/IRateLimitCounterStore.cs index 643513b..d768d4c 100644 --- a/src/core/Deal.Modules.Tenants/Application/IRateLimitCounterStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/IRateLimitCounterStore.cs @@ -1,4 +1,9 @@ -namespace Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Modules.Tenants.Application.Abstractions; /// /// Порт распределённого счётчика фиксированного окна (этап 12, пакет B): таблица diff --git a/src/core/Deal.Modules.Tenants/Application/ITenantLimitStore.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantLimitStore.cs similarity index 97% rename from src/core/Deal.Modules.Tenants/Application/ITenantLimitStore.cs rename to src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantLimitStore.cs index a4318f2..a93016c 100644 --- a/src/core/Deal.Modules.Tenants/Application/ITenantLimitStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantLimitStore.cs @@ -1,6 +1,9 @@ using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; -namespace Deal.Modules.Tenants.Application; +namespace Deal.Modules.Tenants.Application.Abstractions; /// /// Порт хранилища лимитов ИИ-бюджета тенанта: таблица public.tenant_limits (Ruling 3 этапа 7; diff --git a/src/core/Deal.Modules.Tenants/Application/ITenantProvisioner.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantProvisioner.cs similarity index 74% rename from src/core/Deal.Modules.Tenants/Application/ITenantProvisioner.cs rename to src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantProvisioner.cs index abb2a91..135fe98 100644 --- a/src/core/Deal.Modules.Tenants/Application/ITenantProvisioner.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantProvisioner.cs @@ -1,6 +1,10 @@ using Deal.SharedKernel.Tenants; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; -namespace Deal.Modules.Tenants.Application; +namespace Deal.Modules.Tenants.Application.Abstractions; /// /// Порт провижининга схемы тенанта (создание схемы и применение tenant-миграций). diff --git a/src/core/Deal.Modules.Tenants/Application/ITenantRepository.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantRepository.cs similarity index 90% rename from src/core/Deal.Modules.Tenants/Application/ITenantRepository.cs rename to src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantRepository.cs index d546928..2c0b2da 100644 --- a/src/core/Deal.Modules.Tenants/Application/ITenantRepository.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITenantRepository.cs @@ -1,6 +1,9 @@ using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; -namespace Deal.Modules.Tenants.Application; +namespace Deal.Modules.Tenants.Application.Abstractions; /// /// Порт реестра тенантов (реализация — EF-адаптер в Infrastructure, таблица public.tenants). diff --git a/src/core/Deal.Modules.Tenants/Application/ITokenUsageEventStore.cs b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITokenUsageEventStore.cs similarity index 87% rename from src/core/Deal.Modules.Tenants/Application/ITokenUsageEventStore.cs rename to src/core/Deal.Modules.Tenants/Application/Abstractions/ITokenUsageEventStore.cs index 2cc8eff..a641b67 100644 --- a/src/core/Deal.Modules.Tenants/Application/ITokenUsageEventStore.cs +++ b/src/core/Deal.Modules.Tenants/Application/Abstractions/ITokenUsageEventStore.cs @@ -1,6 +1,9 @@ using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; -namespace Deal.Modules.Tenants.Application; +namespace Deal.Modules.Tenants.Application.Abstractions; /// /// Порт хранилища истории расхода токенов: таблица public.token_usage_events (этап 10, T2; diff --git a/src/core/Deal.Modules.Tenants/Application/AuditRecordDtoExtensions.cs b/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs similarity index 85% rename from src/core/Deal.Modules.Tenants/Application/AuditRecordDtoExtensions.cs rename to src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs index f7a461a..9e67e3c 100644 --- a/src/core/Deal.Modules.Tenants/Application/AuditRecordDtoExtensions.cs +++ b/src/core/Deal.Modules.Tenants/Application/Extensions/AuditRecordDtoExtensions.cs @@ -1,3 +1,8 @@ +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; + namespace Deal.Modules.Tenants.Application.Models; /// diff --git a/src/core/Deal.Modules.Tenants/Application/InviteDtoExtensions.cs b/src/core/Deal.Modules.Tenants/Application/Extensions/InviteDtoExtensions.cs similarity index 68% rename from src/core/Deal.Modules.Tenants/Application/InviteDtoExtensions.cs rename to src/core/Deal.Modules.Tenants/Application/Extensions/InviteDtoExtensions.cs index 8658fe1..c79e8be 100644 --- a/src/core/Deal.Modules.Tenants/Application/InviteDtoExtensions.cs +++ b/src/core/Deal.Modules.Tenants/Application/Extensions/InviteDtoExtensions.cs @@ -1,6 +1,9 @@ using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; -namespace Deal.Modules.Tenants.Application; +namespace Deal.Modules.Tenants.Application.Extensions; internal static class InviteDtoExtensions { diff --git a/src/core/Deal.Modules.Tenants/Application/AuditActorTypes.cs b/src/core/Deal.Modules.Tenants/Application/Models/AuditActorTypes.cs similarity index 75% rename from src/core/Deal.Modules.Tenants/Application/AuditActorTypes.cs rename to src/core/Deal.Modules.Tenants/Application/Models/AuditActorTypes.cs index f74c813..69c7fac 100644 --- a/src/core/Deal.Modules.Tenants/Application/AuditActorTypes.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/AuditActorTypes.cs @@ -1,4 +1,9 @@ -namespace Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Modules.Tenants.Application.Models; /// /// Типы акторов аудита (колонка public.audit_log.ActorType; Ruling 4 этапа 7). diff --git a/src/core/Deal.Modules.Tenants/Application/AuditEvents.cs b/src/core/Deal.Modules.Tenants/Application/Models/AuditEvents.cs similarity index 96% rename from src/core/Deal.Modules.Tenants/Application/AuditEvents.cs rename to src/core/Deal.Modules.Tenants/Application/Models/AuditEvents.cs index 580c80a..da7f260 100644 --- a/src/core/Deal.Modules.Tenants/Application/AuditEvents.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/AuditEvents.cs @@ -1,4 +1,9 @@ -namespace Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Modules.Tenants.Application.Models; /// /// Каталог типов событий аудита (колонка public.audit_log.EventType; Ruling 4 этапа 7). diff --git a/src/core/Deal.Modules.Tenants/Application/InviteStatuses.cs b/src/core/Deal.Modules.Tenants/Application/Models/InviteStatuses.cs similarity index 87% rename from src/core/Deal.Modules.Tenants/Application/InviteStatuses.cs rename to src/core/Deal.Modules.Tenants/Application/Models/InviteStatuses.cs index 01236b6..11f547d 100644 --- a/src/core/Deal.Modules.Tenants/Application/InviteStatuses.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/InviteStatuses.cs @@ -1,4 +1,9 @@ -namespace Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Modules.Tenants.Application.Models; /// /// Статусы приглашения (колонка public.invites.Status; Ruling 2 этапа 7). diff --git a/src/core/Deal.Modules.Tenants/Application/TenantLimitPeriods.cs b/src/core/Deal.Modules.Tenants/Application/Models/TenantLimitPeriods.cs similarity index 82% rename from src/core/Deal.Modules.Tenants/Application/TenantLimitPeriods.cs rename to src/core/Deal.Modules.Tenants/Application/Models/TenantLimitPeriods.cs index 0ba6466..6fca297 100644 --- a/src/core/Deal.Modules.Tenants/Application/TenantLimitPeriods.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TenantLimitPeriods.cs @@ -1,4 +1,9 @@ -namespace Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Modules.Tenants.Application.Models; /// /// Типы периода бюджета тенанта (колонка public.tenant_limits.Period; Ruling 3 этапа 7). diff --git a/src/core/Deal.Modules.Tenants/Application/TenantStatuses.cs b/src/core/Deal.Modules.Tenants/Application/Models/TenantStatuses.cs similarity index 87% rename from src/core/Deal.Modules.Tenants/Application/TenantStatuses.cs rename to src/core/Deal.Modules.Tenants/Application/Models/TenantStatuses.cs index 8bc2405..f352a26 100644 --- a/src/core/Deal.Modules.Tenants/Application/TenantStatuses.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TenantStatuses.cs @@ -1,4 +1,9 @@ -namespace Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Modules.Tenants.Application.Models; /// /// Статусы тенанта (колонка public.tenants.Status; Ruling 1/10 этапа 7). diff --git a/src/core/Deal.Modules.Tenants/Application/TokenBudgetDefaults.cs b/src/core/Deal.Modules.Tenants/Application/Models/TokenBudgetDefaults.cs similarity index 86% rename from src/core/Deal.Modules.Tenants/Application/TokenBudgetDefaults.cs rename to src/core/Deal.Modules.Tenants/Application/Models/TokenBudgetDefaults.cs index 1444f95..1df3ca5 100644 --- a/src/core/Deal.Modules.Tenants/Application/TokenBudgetDefaults.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TokenBudgetDefaults.cs @@ -1,6 +1,10 @@ using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; -namespace Deal.Modules.Tenants.Application; +namespace Deal.Modules.Tenants.Application.Models; /// /// Дефолт-бюджет нового тенанта (Ruling 3 этапа 7). diff --git a/src/core/Deal.Modules.Tenants/Application/TokenUsageEventKinds.cs b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventKinds.cs similarity index 68% rename from src/core/Deal.Modules.Tenants/Application/TokenUsageEventKinds.cs rename to src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventKinds.cs index f6aee41..45a1dc9 100644 --- a/src/core/Deal.Modules.Tenants/Application/TokenUsageEventKinds.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageEventKinds.cs @@ -1,4 +1,9 @@ -namespace Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Modules.Tenants.Application.Models; /// /// Виды вызова расхода токенов (колонка public.token_usage_events.Kind; этап 10, T2). diff --git a/src/core/Deal.Modules.Tenants/Application/TokenUsageGroupBys.cs b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageGroupBys.cs similarity index 75% rename from src/core/Deal.Modules.Tenants/Application/TokenUsageGroupBys.cs rename to src/core/Deal.Modules.Tenants/Application/Models/TokenUsageGroupBys.cs index d5bbdae..82c3676 100644 --- a/src/core/Deal.Modules.Tenants/Application/TokenUsageGroupBys.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageGroupBys.cs @@ -1,4 +1,9 @@ -namespace Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Modules.Tenants.Application.Models; /// /// Способы группировки агрегатов расхода токенов (query groupBy эндпоинта аналитики; этап 10, T3). diff --git a/src/core/Deal.Modules.Tenants/Application/TokenUsageSources.cs b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageSources.cs similarity index 75% rename from src/core/Deal.Modules.Tenants/Application/TokenUsageSources.cs rename to src/core/Deal.Modules.Tenants/Application/Models/TokenUsageSources.cs index 11bf80d..615c098 100644 --- a/src/core/Deal.Modules.Tenants/Application/TokenUsageSources.cs +++ b/src/core/Deal.Modules.Tenants/Application/Models/TokenUsageSources.cs @@ -1,4 +1,9 @@ -namespace Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Modules.Tenants.Application.Models; /// /// Источники расхода токенов (значения колонок public.token_usage_events.Provider/Model; этап 10, T2). diff --git a/src/core/Deal.Modules.Tenants/Application/TenantModuleRegistrar.cs b/src/core/Deal.Modules.Tenants/Application/Registrars/TenantModuleRegistrar.cs similarity index 90% rename from src/core/Deal.Modules.Tenants/Application/TenantModuleRegistrar.cs rename to src/core/Deal.Modules.Tenants/Application/Registrars/TenantModuleRegistrar.cs index 5858b95..9f017fe 100644 --- a/src/core/Deal.Modules.Tenants/Application/TenantModuleRegistrar.cs +++ b/src/core/Deal.Modules.Tenants/Application/Registrars/TenantModuleRegistrar.cs @@ -1,6 +1,10 @@ using Microsoft.Extensions.DependencyInjection; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Services; -namespace Deal.Modules.Tenants.Application; +namespace Deal.Modules.Tenants.Application.Registrars; /// /// DI-регистрация модуля Tenants. Паттерн «port & adapter» (Ruling 1). diff --git a/src/core/Deal.Modules.Tenants/Application/AnalyticsService.cs b/src/core/Deal.Modules.Tenants/Application/Services/AnalyticsService.cs similarity index 96% rename from src/core/Deal.Modules.Tenants/Application/AnalyticsService.cs rename to src/core/Deal.Modules.Tenants/Application/Services/AnalyticsService.cs index 962c125..97e8e66 100644 --- a/src/core/Deal.Modules.Tenants/Application/AnalyticsService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/AnalyticsService.cs @@ -1,6 +1,9 @@ using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; -namespace Deal.Modules.Tenants.Application; +namespace Deal.Modules.Tenants.Application.Services; /// /// Прикладной сервис операторской аналитики (этап 10, T3): сводка, агрегаты токенов и лента действий. diff --git a/src/core/Deal.Modules.Tenants/Application/AuditService.cs b/src/core/Deal.Modules.Tenants/Application/Services/AuditService.cs similarity index 96% rename from src/core/Deal.Modules.Tenants/Application/AuditService.cs rename to src/core/Deal.Modules.Tenants/Application/Services/AuditService.cs index 7e95be3..639422c 100644 --- a/src/core/Deal.Modules.Tenants/Application/AuditService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/AuditService.cs @@ -1,8 +1,11 @@ using System.Text.Json; using Deal.Modules.Tenants.Application.Models; using Deal.SharedKernel.Observability; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; -namespace Deal.Modules.Tenants.Application; +namespace Deal.Modules.Tenants.Application.Services; /// /// Прикладной сервис аудита (Ruling 4 этапа 7): append-only запись событий и чтение ленты оператором. diff --git a/src/core/Deal.Modules.Tenants/Application/AuthService.cs b/src/core/Deal.Modules.Tenants/Application/Services/AuthService.cs similarity index 98% rename from src/core/Deal.Modules.Tenants/Application/AuthService.cs rename to src/core/Deal.Modules.Tenants/Application/Services/AuthService.cs index 9d7efaf..0c4c283 100644 --- a/src/core/Deal.Modules.Tenants/Application/AuthService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/AuthService.cs @@ -1,6 +1,9 @@ using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; -namespace Deal.Modules.Tenants.Application; +namespace Deal.Modules.Tenants.Application.Services; /// /// Прикладной сервис аутентификации: login, logout, смена пароля, разрешение сессии, impersonation. diff --git a/src/core/Deal.Modules.Tenants/Application/DefaultPasswordHasher.cs b/src/core/Deal.Modules.Tenants/Application/Services/DefaultPasswordHasher.cs similarity index 76% rename from src/core/Deal.Modules.Tenants/Application/DefaultPasswordHasher.cs rename to src/core/Deal.Modules.Tenants/Application/Services/DefaultPasswordHasher.cs index 4337230..03df8e8 100644 --- a/src/core/Deal.Modules.Tenants/Application/DefaultPasswordHasher.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/DefaultPasswordHasher.cs @@ -1,6 +1,10 @@ using Isopoh.Cryptography.Argon2; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; -namespace Deal.Modules.Tenants.Application; +namespace Deal.Modules.Tenants.Application.Services; /// /// Реализация на Argon2id (Ruling 5). diff --git a/src/core/Deal.Modules.Tenants/Application/InviteCodeGenerator.cs b/src/core/Deal.Modules.Tenants/Application/Services/InviteCodeGenerator.cs similarity index 85% rename from src/core/Deal.Modules.Tenants/Application/InviteCodeGenerator.cs rename to src/core/Deal.Modules.Tenants/Application/Services/InviteCodeGenerator.cs index 90a4d79..a676e07 100644 --- a/src/core/Deal.Modules.Tenants/Application/InviteCodeGenerator.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/InviteCodeGenerator.cs @@ -1,6 +1,10 @@ using Deal.SharedKernel; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; -namespace Deal.Modules.Tenants.Application; +namespace Deal.Modules.Tenants.Application.Services; /// /// Генератор кодов приглашений: случайный url-safe код, 16 символов, без префикса (Ruling 2 этапа 7). diff --git a/src/core/Deal.Modules.Tenants/Application/InvitesService.cs b/src/core/Deal.Modules.Tenants/Application/Services/InvitesService.cs similarity index 98% rename from src/core/Deal.Modules.Tenants/Application/InvitesService.cs rename to src/core/Deal.Modules.Tenants/Application/Services/InvitesService.cs index e8ef8f3..285ab46 100644 --- a/src/core/Deal.Modules.Tenants/Application/InvitesService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/InvitesService.cs @@ -1,7 +1,10 @@ using System.Text.RegularExpressions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; -namespace Deal.Modules.Tenants.Application; +namespace Deal.Modules.Tenants.Application.Services; /// /// Прикладной сервис приглашений (Ruling 2 этапа 7): создание оператором, отзыв, список, чтение по коду. diff --git a/src/core/Deal.Modules.Tenants/Application/JoinService.cs b/src/core/Deal.Modules.Tenants/Application/Services/JoinService.cs similarity index 98% rename from src/core/Deal.Modules.Tenants/Application/JoinService.cs rename to src/core/Deal.Modules.Tenants/Application/Services/JoinService.cs index 6b36aaf..3ae02da 100644 --- a/src/core/Deal.Modules.Tenants/Application/JoinService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/JoinService.cs @@ -1,6 +1,9 @@ using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; -namespace Deal.Modules.Tenants.Application; +namespace Deal.Modules.Tenants.Application.Services; /// /// Прикладной сервис активации инвайта через публичную ручку POST /api/join (Ruling 2, Task 6 этапа 7): diff --git a/src/core/Deal.Modules.Tenants/Application/OperatorAuthService.cs b/src/core/Deal.Modules.Tenants/Application/Services/OperatorAuthService.cs similarity index 97% rename from src/core/Deal.Modules.Tenants/Application/OperatorAuthService.cs rename to src/core/Deal.Modules.Tenants/Application/Services/OperatorAuthService.cs index e2a7ec2..1189c50 100644 --- a/src/core/Deal.Modules.Tenants/Application/OperatorAuthService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/OperatorAuthService.cs @@ -1,6 +1,9 @@ using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; -namespace Deal.Modules.Tenants.Application; +namespace Deal.Modules.Tenants.Application.Services; /// /// Прикладной сервис аутентификации оператора (Ruling 1 этапа 7): login, logout, разрешение сессии. diff --git a/src/core/Deal.Modules.Tenants/Application/OperatorBootstrapService.cs b/src/core/Deal.Modules.Tenants/Application/Services/OperatorBootstrapService.cs similarity index 95% rename from src/core/Deal.Modules.Tenants/Application/OperatorBootstrapService.cs rename to src/core/Deal.Modules.Tenants/Application/Services/OperatorBootstrapService.cs index 27003db..e459f10 100644 --- a/src/core/Deal.Modules.Tenants/Application/OperatorBootstrapService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/OperatorBootstrapService.cs @@ -1,6 +1,9 @@ using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; -namespace Deal.Modules.Tenants.Application; +namespace Deal.Modules.Tenants.Application.Services; /// /// Bootstrap оператора при старте (Ruling 1 этапа 7): идемпотентный seed из env DEAL_OPERATOR_*. diff --git a/src/core/Deal.Modules.Tenants/Application/SessionTokens.cs b/src/core/Deal.Modules.Tenants/Application/Services/SessionTokens.cs similarity index 84% rename from src/core/Deal.Modules.Tenants/Application/SessionTokens.cs rename to src/core/Deal.Modules.Tenants/Application/Services/SessionTokens.cs index 636d33d..84d1a0f 100644 --- a/src/core/Deal.Modules.Tenants/Application/SessionTokens.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/SessionTokens.cs @@ -1,8 +1,12 @@ using System.Security.Cryptography; using System.Text; using Deal.SharedKernel; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; -namespace Deal.Modules.Tenants.Application; +namespace Deal.Modules.Tenants.Application.Services; /// /// Токены сессий: генерация raw-токена и его SHA-256-хэша для хранения (Ruling 6). diff --git a/src/core/Deal.Modules.Tenants/Application/SuspiciousActivityService.cs b/src/core/Deal.Modules.Tenants/Application/Services/SuspiciousActivityService.cs similarity index 96% rename from src/core/Deal.Modules.Tenants/Application/SuspiciousActivityService.cs rename to src/core/Deal.Modules.Tenants/Application/Services/SuspiciousActivityService.cs index 0074435..7e8516f 100644 --- a/src/core/Deal.Modules.Tenants/Application/SuspiciousActivityService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/SuspiciousActivityService.cs @@ -1,7 +1,10 @@ using System.Text.Json; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; -namespace Deal.Modules.Tenants.Application; +namespace Deal.Modules.Tenants.Application.Services; /// /// Детектор подозрительной активности по логам безопасности (§10.5): правила поверх аудита. diff --git a/src/core/Deal.Modules.Tenants/Application/TenantAdminService.cs b/src/core/Deal.Modules.Tenants/Application/Services/TenantAdminService.cs similarity index 97% rename from src/core/Deal.Modules.Tenants/Application/TenantAdminService.cs rename to src/core/Deal.Modules.Tenants/Application/Services/TenantAdminService.cs index 554c8ec..8594989 100644 --- a/src/core/Deal.Modules.Tenants/Application/TenantAdminService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/TenantAdminService.cs @@ -1,7 +1,10 @@ using Deal.Modules.Tenants.Application.Models; using Deal.SharedKernel; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; -namespace Deal.Modules.Tenants.Application; +namespace Deal.Modules.Tenants.Application.Services; /// /// Прикладной сервис операторского реестра тенантов (GET /api/operator/tenants[/{id}], POST (создание), diff --git a/src/core/Deal.Modules.Tenants/Application/TenantService.cs b/src/core/Deal.Modules.Tenants/Application/Services/TenantService.cs similarity index 92% rename from src/core/Deal.Modules.Tenants/Application/TenantService.cs rename to src/core/Deal.Modules.Tenants/Application/Services/TenantService.cs index 162addc..145ac7d 100644 --- a/src/core/Deal.Modules.Tenants/Application/TenantService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/TenantService.cs @@ -1,7 +1,10 @@ using Deal.Modules.Tenants.Application.Models; using Deal.SharedKernel.Tenants; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; -namespace Deal.Modules.Tenants.Application; +namespace Deal.Modules.Tenants.Application.Services; /// /// Прикладной сервис реестра тенантов: создание тенанта и его списка. diff --git a/src/core/Deal.Modules.Tenants/Application/TokenBudgetService.cs b/src/core/Deal.Modules.Tenants/Application/Services/TokenBudgetService.cs similarity index 94% rename from src/core/Deal.Modules.Tenants/Application/TokenBudgetService.cs rename to src/core/Deal.Modules.Tenants/Application/Services/TokenBudgetService.cs index e9c45f9..0c960dc 100644 --- a/src/core/Deal.Modules.Tenants/Application/TokenBudgetService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/TokenBudgetService.cs @@ -1,4 +1,9 @@ -namespace Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; + +namespace Deal.Modules.Tenants.Application.Services; /// /// Период-математика лимитов ИИ-бюджета (Task 8, Ruling 3 этапа 7): конец окна периода для ленивого diff --git a/src/core/Deal.Modules.Tenants/Application/TokenUsageEventService.cs b/src/core/Deal.Modules.Tenants/Application/Services/TokenUsageEventService.cs similarity index 87% rename from src/core/Deal.Modules.Tenants/Application/TokenUsageEventService.cs rename to src/core/Deal.Modules.Tenants/Application/Services/TokenUsageEventService.cs index 14495e1..27f3a3e 100644 --- a/src/core/Deal.Modules.Tenants/Application/TokenUsageEventService.cs +++ b/src/core/Deal.Modules.Tenants/Application/Services/TokenUsageEventService.cs @@ -1,6 +1,9 @@ using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Registrars; -namespace Deal.Modules.Tenants.Application; +namespace Deal.Modules.Tenants.Application.Services; /// /// Прикладной сервис истории расхода токенов (этап 10, T2): единая точка записи и чтения агрегатов. diff --git a/src/core/tests/Deal.Tests.Unit/AdminTickOrchestratorTests.cs b/src/core/tests/Deal.Tests.Unit/AdminTickOrchestratorTests.cs index e0155c7..2d986e1 100644 --- a/src/core/tests/Deal.Tests.Unit/AdminTickOrchestratorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/AdminTickOrchestratorTests.cs @@ -2,12 +2,20 @@ using System.Text.Json; using Deal.Api; using Deal.Api.Events; using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; using Microsoft.Extensions.Logging.Abstractions; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/AiClassifyContextBuilderTests.cs b/src/core/tests/Deal.Tests.Unit/AiClassifyContextBuilderTests.cs index 0ffda7c..d03fc3c 100644 --- a/src/core/tests/Deal.Tests.Unit/AiClassifyContextBuilderTests.cs +++ b/src/core/tests/Deal.Tests.Unit/AiClassifyContextBuilderTests.cs @@ -1,6 +1,12 @@ using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application; -using Deal.Modules.Settings.Application; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/AiRawCardMapperTests.cs b/src/core/tests/Deal.Tests.Unit/AiRawCardMapperTests.cs index f3be9b0..8fb442c 100644 --- a/src/core/tests/Deal.Tests.Unit/AiRawCardMapperTests.cs +++ b/src/core/tests/Deal.Tests.Unit/AiRawCardMapperTests.cs @@ -1,6 +1,9 @@ using System.Text.Json; using Deal.Contracts.Integrations.Models; -using Deal.Modules.Pipeline.Application; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/AuditEventsTests.cs b/src/core/tests/Deal.Tests.Unit/AuditEventsTests.cs index 235b035..2183d98 100644 --- a/src/core/tests/Deal.Tests.Unit/AuditEventsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/AuditEventsTests.cs @@ -1,4 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/AuditLogStoreTests.cs b/src/core/tests/Deal.Tests.Unit/AuditLogStoreTests.cs index 59260e7..e96d23c 100644 --- a/src/core/tests/Deal.Tests.Unit/AuditLogStoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/AuditLogStoreTests.cs @@ -1,7 +1,10 @@ using Deal.Infrastructure.Persistence; using Deal.Infrastructure.Persistence.Repositories; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Microsoft.EntityFrameworkCore; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/AuditServiceTests.cs b/src/core/tests/Deal.Tests.Unit/AuditServiceTests.cs index b313d05..44998d3 100644 --- a/src/core/tests/Deal.Tests.Unit/AuditServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/AuditServiceTests.cs @@ -1,5 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/AuthServiceTests.cs b/src/core/tests/Deal.Tests.Unit/AuthServiceTests.cs index fcfc458..6de7a99 100644 --- a/src/core/tests/Deal.Tests.Unit/AuthServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/AuthServiceTests.cs @@ -1,5 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/AuthStoreTests.cs b/src/core/tests/Deal.Tests.Unit/AuthStoreTests.cs index 8e011ba..b609244 100644 --- a/src/core/tests/Deal.Tests.Unit/AuthStoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/AuthStoreTests.cs @@ -1,7 +1,10 @@ using Deal.Infrastructure.Persistence; using Deal.Infrastructure.Persistence.Repositories; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Microsoft.EntityFrameworkCore; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/BudgetAlertSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/BudgetAlertSchedulerTests.cs index 597936e..4ca1acb 100644 --- a/src/core/tests/Deal.Tests.Unit/BudgetAlertSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/BudgetAlertSchedulerTests.cs @@ -1,191 +1,194 @@ -using System.Text.Json; -using Deal.Api.Events; -using Deal.Api.Hosting; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Tests.Unit; - -/// -/// Тесты логики прохода (план Task 9, Ruling 3): фоновая проверка -/// порогов ИИ-бюджета — обход всех тенантов реестра и SSE-тост в канал тенанта при пересечении 80%/100%; -/// TryMark*-CAS (Task 8) гарантирует один тост на порог за период. -/// -/// -/// Тайминги цикла (Timer 60 с, первый проход, stop) не тестируются — тестируется итерация через публичный -/// . Скоупы/DI поднимаются на реальном ServiceCollection с фейками: -/// ITenantRepository — FakeTenantRepository, ITenantLimitStore — FakeTenantLimitStore (scoped, поведение зеркалит -/// EF-адаптер: TryMark* возвращает true только при «флаг не стоял и порог достигнут»). Публикации проверяются -/// реальным SseBroker с подпиской канала (как в StorageTickSchedulerTests). -/// -public sealed class BudgetAlertSchedulerTests -{ - // Тенант A теста (канал подписки). - private static readonly Guid TenantA = Guid.NewGuid(); - - // Тенант B теста (канал подписки). - private static readonly Guid TenantB = Guid.NewGuid(); - - // Текст тоста порога 80% (зеркало BudgetAlertScheduler, Ruling 3). - private const string Warned80ToastText = "ИИ-бюджет израсходован на 80%"; - - // Текст тоста исчерпания (зеркало BudgetAlertScheduler, Ruling 3). - private const string ExhaustedToastText = "ИИ-бюджет исчерпан — обработка в локальном режиме"; - - [Fact] - public async Task RunCycle_TwoCycles_PublishesToastOncePerThresholdPerTenant() - { - // Acceptance Task 9 «тост один раз на порог»: A на пороге 80%, B исчерпан (оба порога за период). - FakeTenantLimitStore limits = new(); - limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 800); - limits.Preload(TenantB, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 1000); - await using ServiceProvider provider = BuildProvider( - new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), limits); - - SseBroker broker = provider.GetRequiredService(); - SseSubscription subscriptionA = broker.Subscribe(TenantA); - SseSubscription subscriptionB = broker.Subscribe(TenantB); - BudgetAlertScheduler scheduler = CreateScheduler(provider); - - await scheduler.RunCycleAsync(CancellationToken.None); - - // A: один тост 80%; B: 80% и затем исчерпание (порядок публикации — 80% → 100%, Ruling 3). - Assert.Equal(new[] { (Warned80ToastText, "bell") }, ReadToasts(subscriptionA)); - Assert.Equal( - new[] { (Warned80ToastText, "bell"), (ExhaustedToastText, "bell") }, - ReadToasts(subscriptionB)); - - // Второй проход: флаги уже стоят (TryMark* вернул false) — тосты не задваиваются. - await scheduler.RunCycleAsync(CancellationToken.None); - Assert.Empty(ReadToasts(subscriptionA)); - Assert.Empty(ReadToasts(subscriptionB)); - } - - [Fact] - public async Task RunCycle_TenantBelowThreshold_NoToastAndNoFlagSet() - { - FakeTenantLimitStore limits = new(); - limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 700); - await using ServiceProvider provider = BuildProvider(new FakeTenantRepository(Tenant(TenantA)), limits); - - SseBroker broker = provider.GetRequiredService(); - SseSubscription subscriptionA = broker.Subscribe(TenantA); - BudgetAlertScheduler scheduler = CreateScheduler(provider); - - await scheduler.RunCycleAsync(CancellationToken.None); - - Assert.Empty(ReadToasts(subscriptionA)); - // Порог не достигнут — TryMark* не выставил флаг (повторный проход после роста расхода даст тост). - Assert.False(await limits.TryMarkWarnedAsync(TenantA, CancellationToken.None)); - } - - [Fact] - public async Task RunCycle_ExhaustionWithoutWarned80_PublishesBothToastsOnce() - { - // Строка «уже исчерпан, но ни один флаг не стоял» (сценарий: оператор уменьшил бюджет — флаги сброшены, - // расход ≥ бюджета): за один проход выходят оба порога ровно по одному разу. - FakeTenantLimitStore limits = new(); - limits.Preload(TenantA, budgetTokens: 500, TenantLimitPeriods.Month, Now(), usedTokens: 700); - await using ServiceProvider provider = BuildProvider(new FakeTenantRepository(Tenant(TenantA)), limits); - - SseBroker broker = provider.GetRequiredService(); - SseSubscription subscriptionA = broker.Subscribe(TenantA); - BudgetAlertScheduler scheduler = CreateScheduler(provider); - - await scheduler.RunCycleAsync(CancellationToken.None); - await scheduler.RunCycleAsync(CancellationToken.None); - - Assert.Equal( - new[] { (Warned80ToastText, "bell"), (ExhaustedToastText, "bell") }, - ReadToasts(subscriptionA)); - } - - [Fact] - public async Task RunCycle_NaturalSpendCrossing80Then100_PublishesOneToastPerThreshold() - { - // Review-fix Task 9: флаги порогов выставляет только TryMark* — естественный расход (AddUsage) пересекает - // 80% → ближайший проход публикует РОВНО один тост; повторный проход — без тоста; пересечение 100% (ещё - // расход) → ещё ровно один тост (100%), 80% не дублируется. - FakeTenantLimitStore limits = new(); - limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 0); - await using ServiceProvider provider = BuildProvider(new FakeTenantRepository(Tenant(TenantA)), limits); - - SseBroker broker = provider.GetRequiredService(); - SseSubscription subscriptionA = broker.Subscribe(TenantA); - BudgetAlertScheduler scheduler = CreateScheduler(provider); - - // Расход до порога 80% (850 ≥ floor(0.8·1000)): флаги списанием не выставляются (review-fix Task 9) — - // переход остаётся непомеченным, и TryMark* на проходе вернёт true ровно один раз. - await limits.AddUsageAsync(TenantA, tokens: 850, CancellationToken.None); - - await scheduler.RunCycleAsync(CancellationToken.None); - Assert.Equal(new[] { (Warned80ToastText, "bell") }, ReadToasts(subscriptionA)); - - await scheduler.RunCycleAsync(CancellationToken.None); - Assert.Empty(ReadToasts(subscriptionA)); // повторный проход — флаг уже стоит, тост не дублируется - - // Расход до исчерпания (850 + 200 = 1050 ≥ 1000) → ещё ровно один тост (100%); 80% уже отмечен. - await limits.AddUsageAsync(TenantA, tokens: 200, CancellationToken.None); - await scheduler.RunCycleAsync(CancellationToken.None); - Assert.Equal(new[] { (ExhaustedToastText, "bell") }, ReadToasts(subscriptionA)); - - await scheduler.RunCycleAsync(CancellationToken.None); - Assert.Empty(ReadToasts(subscriptionA)); - } - - // ─── Контекст сценария ───────────────────────────────────────────────── - - // Строит DI-провайдер теста: реестр тенантов + scoped фейк лимитов + SseBroker. - // tenants: Фейк реестра тенантов (обход прохода). - // limits: Фейк-хранилище лимитов (строки посеяны сценарием до прохода). - // Возвращает: Провайдер с сервисами цикла. - private static ServiceProvider BuildProvider(ITenantRepository tenants, FakeTenantLimitStore limits) - { - var services = new ServiceCollection(); - services.AddSingleton(tenants); - services.AddScoped(_ => limits); - services.AddSingleton(); - return services.BuildServiceProvider(); - } - - // Создаёт планировщик на провайдере теста (без StartAsync — таймер 60 с не заводим). - // provider: DI-провайдер с сервисами цикла. - // Возвращает: Планировщик с NullLogger. - private static BudgetAlertScheduler CreateScheduler(ServiceProvider provider) - { - return new BudgetAlertScheduler( - provider.GetRequiredService(), - provider.GetRequiredService(), - NullLogger.Instance); - } - - // Запись реестра тенанта (как строка public.tenants). - // id: Идентификатор тенанта. - // Возвращает: Запись тенанта со статусом active. - private static TenantRecordDto Tenant(Guid id) => - new(id, Name: "tenant", Status: TenantStatuses.Active, CreatedAt: DateTimeOffset.UtcNow); - - // Начало периода строк лимита («сейчас» — месяц не истёк, ленивый reset не сработает). - // Возвращает: Текущий момент (UTC). - private static DateTimeOffset Now() => DateTimeOffset.UtcNow; - - // Читает опубликованные тосты канала: (text, icon) в порядке публикации. - // subscription: Подписка тенанта-получателя. - // Возвращает: Пары text/icon событий toast канала. - private static List<(string Text, string Icon)> ReadToasts(SseSubscription subscription) - { - var toasts = new List<(string Text, string Icon)>(); - while (subscription.Events.TryRead(out SseEvent? sseEvent)) - { - Assert.Equal("toast", sseEvent!.Type); - using JsonDocument payload = JsonDocument.Parse(sseEvent.Json); - toasts.Add(( - payload.RootElement.GetProperty("text").GetString()!, - payload.RootElement.GetProperty("icon").GetString()!)); - } - - return toasts; - } -} +using System.Text.Json; +using Deal.Api.Events; +using Deal.Api.Hosting; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Tests.Unit; + +/// +/// Тесты логики прохода (план Task 9, Ruling 3): фоновая проверка +/// порогов ИИ-бюджета — обход всех тенантов реестра и SSE-тост в канал тенанта при пересечении 80%/100%; +/// TryMark*-CAS (Task 8) гарантирует один тост на порог за период. +/// +/// +/// Тайминги цикла (Timer 60 с, первый проход, stop) не тестируются — тестируется итерация через публичный +/// . Скоупы/DI поднимаются на реальном ServiceCollection с фейками: +/// ITenantRepository — FakeTenantRepository, ITenantLimitStore — FakeTenantLimitStore (scoped, поведение зеркалит +/// EF-адаптер: TryMark* возвращает true только при «флаг не стоял и порог достигнут»). Публикации проверяются +/// реальным SseBroker с подпиской канала (как в StorageTickSchedulerTests). +/// +public sealed class BudgetAlertSchedulerTests +{ + // Тенант A теста (канал подписки). + private static readonly Guid TenantA = Guid.NewGuid(); + + // Тенант B теста (канал подписки). + private static readonly Guid TenantB = Guid.NewGuid(); + + // Текст тоста порога 80% (зеркало BudgetAlertScheduler, Ruling 3). + private const string Warned80ToastText = "ИИ-бюджет израсходован на 80%"; + + // Текст тоста исчерпания (зеркало BudgetAlertScheduler, Ruling 3). + private const string ExhaustedToastText = "ИИ-бюджет исчерпан — обработка в локальном режиме"; + + [Fact] + public async Task RunCycle_TwoCycles_PublishesToastOncePerThresholdPerTenant() + { + // Acceptance Task 9 «тост один раз на порог»: A на пороге 80%, B исчерпан (оба порога за период). + FakeTenantLimitStore limits = new(); + limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 800); + limits.Preload(TenantB, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 1000); + await using ServiceProvider provider = BuildProvider( + new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), limits); + + SseBroker broker = provider.GetRequiredService(); + SseSubscription subscriptionA = broker.Subscribe(TenantA); + SseSubscription subscriptionB = broker.Subscribe(TenantB); + BudgetAlertScheduler scheduler = CreateScheduler(provider); + + await scheduler.RunCycleAsync(CancellationToken.None); + + // A: один тост 80%; B: 80% и затем исчерпание (порядок публикации — 80% → 100%, Ruling 3). + Assert.Equal(new[] { (Warned80ToastText, "bell") }, ReadToasts(subscriptionA)); + Assert.Equal( + new[] { (Warned80ToastText, "bell"), (ExhaustedToastText, "bell") }, + ReadToasts(subscriptionB)); + + // Второй проход: флаги уже стоят (TryMark* вернул false) — тосты не задваиваются. + await scheduler.RunCycleAsync(CancellationToken.None); + Assert.Empty(ReadToasts(subscriptionA)); + Assert.Empty(ReadToasts(subscriptionB)); + } + + [Fact] + public async Task RunCycle_TenantBelowThreshold_NoToastAndNoFlagSet() + { + FakeTenantLimitStore limits = new(); + limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 700); + await using ServiceProvider provider = BuildProvider(new FakeTenantRepository(Tenant(TenantA)), limits); + + SseBroker broker = provider.GetRequiredService(); + SseSubscription subscriptionA = broker.Subscribe(TenantA); + BudgetAlertScheduler scheduler = CreateScheduler(provider); + + await scheduler.RunCycleAsync(CancellationToken.None); + + Assert.Empty(ReadToasts(subscriptionA)); + // Порог не достигнут — TryMark* не выставил флаг (повторный проход после роста расхода даст тост). + Assert.False(await limits.TryMarkWarnedAsync(TenantA, CancellationToken.None)); + } + + [Fact] + public async Task RunCycle_ExhaustionWithoutWarned80_PublishesBothToastsOnce() + { + // Строка «уже исчерпан, но ни один флаг не стоял» (сценарий: оператор уменьшил бюджет — флаги сброшены, + // расход ≥ бюджета): за один проход выходят оба порога ровно по одному разу. + FakeTenantLimitStore limits = new(); + limits.Preload(TenantA, budgetTokens: 500, TenantLimitPeriods.Month, Now(), usedTokens: 700); + await using ServiceProvider provider = BuildProvider(new FakeTenantRepository(Tenant(TenantA)), limits); + + SseBroker broker = provider.GetRequiredService(); + SseSubscription subscriptionA = broker.Subscribe(TenantA); + BudgetAlertScheduler scheduler = CreateScheduler(provider); + + await scheduler.RunCycleAsync(CancellationToken.None); + await scheduler.RunCycleAsync(CancellationToken.None); + + Assert.Equal( + new[] { (Warned80ToastText, "bell"), (ExhaustedToastText, "bell") }, + ReadToasts(subscriptionA)); + } + + [Fact] + public async Task RunCycle_NaturalSpendCrossing80Then100_PublishesOneToastPerThreshold() + { + // Review-fix Task 9: флаги порогов выставляет только TryMark* — естественный расход (AddUsage) пересекает + // 80% → ближайший проход публикует РОВНО один тост; повторный проход — без тоста; пересечение 100% (ещё + // расход) → ещё ровно один тост (100%), 80% не дублируется. + FakeTenantLimitStore limits = new(); + limits.Preload(TenantA, budgetTokens: 1000, TenantLimitPeriods.Month, Now(), usedTokens: 0); + await using ServiceProvider provider = BuildProvider(new FakeTenantRepository(Tenant(TenantA)), limits); + + SseBroker broker = provider.GetRequiredService(); + SseSubscription subscriptionA = broker.Subscribe(TenantA); + BudgetAlertScheduler scheduler = CreateScheduler(provider); + + // Расход до порога 80% (850 ≥ floor(0.8·1000)): флаги списанием не выставляются (review-fix Task 9) — + // переход остаётся непомеченным, и TryMark* на проходе вернёт true ровно один раз. + await limits.AddUsageAsync(TenantA, tokens: 850, CancellationToken.None); + + await scheduler.RunCycleAsync(CancellationToken.None); + Assert.Equal(new[] { (Warned80ToastText, "bell") }, ReadToasts(subscriptionA)); + + await scheduler.RunCycleAsync(CancellationToken.None); + Assert.Empty(ReadToasts(subscriptionA)); // повторный проход — флаг уже стоит, тост не дублируется + + // Расход до исчерпания (850 + 200 = 1050 ≥ 1000) → ещё ровно один тост (100%); 80% уже отмечен. + await limits.AddUsageAsync(TenantA, tokens: 200, CancellationToken.None); + await scheduler.RunCycleAsync(CancellationToken.None); + Assert.Equal(new[] { (ExhaustedToastText, "bell") }, ReadToasts(subscriptionA)); + + await scheduler.RunCycleAsync(CancellationToken.None); + Assert.Empty(ReadToasts(subscriptionA)); + } + + // ─── Контекст сценария ───────────────────────────────────────────────── + + // Строит DI-провайдер теста: реестр тенантов + scoped фейк лимитов + SseBroker. + // tenants: Фейк реестра тенантов (обход прохода). + // limits: Фейк-хранилище лимитов (строки посеяны сценарием до прохода). + // Возвращает: Провайдер с сервисами цикла. + private static ServiceProvider BuildProvider(ITenantRepository tenants, FakeTenantLimitStore limits) + { + var services = new ServiceCollection(); + services.AddSingleton(tenants); + services.AddScoped(_ => limits); + services.AddSingleton(); + return services.BuildServiceProvider(); + } + + // Создаёт планировщик на провайдере теста (без StartAsync — таймер 60 с не заводим). + // provider: DI-провайдер с сервисами цикла. + // Возвращает: Планировщик с NullLogger. + private static BudgetAlertScheduler CreateScheduler(ServiceProvider provider) + { + return new BudgetAlertScheduler( + provider.GetRequiredService(), + provider.GetRequiredService(), + NullLogger.Instance); + } + + // Запись реестра тенанта (как строка public.tenants). + // id: Идентификатор тенанта. + // Возвращает: Запись тенанта со статусом active. + private static TenantRecordDto Tenant(Guid id) => + new(id, Name: "tenant", Status: TenantStatuses.Active, CreatedAt: DateTimeOffset.UtcNow); + + // Начало периода строк лимита («сейчас» — месяц не истёк, ленивый reset не сработает). + // Возвращает: Текущий момент (UTC). + private static DateTimeOffset Now() => DateTimeOffset.UtcNow; + + // Читает опубликованные тосты канала: (text, icon) в порядке публикации. + // subscription: Подписка тенанта-получателя. + // Возвращает: Пары text/icon событий toast канала. + private static List<(string Text, string Icon)> ReadToasts(SseSubscription subscription) + { + var toasts = new List<(string Text, string Icon)>(); + while (subscription.Events.TryRead(out SseEvent? sseEvent)) + { + Assert.Equal("toast", sseEvent!.Type); + using JsonDocument payload = JsonDocument.Parse(sseEvent.Json); + toasts.Add(( + payload.RootElement.GetProperty("text").GetString()!, + payload.RootElement.GetProperty("icon").GetString()!)); + } + + return toasts; + } +} diff --git a/src/core/tests/Deal.Tests.Unit/BudgetNormalizerTests.cs b/src/core/tests/Deal.Tests.Unit/BudgetNormalizerTests.cs index ea91f7d..8ab024e 100644 --- a/src/core/tests/Deal.Tests.Unit/BudgetNormalizerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/BudgetNormalizerTests.cs @@ -1,5 +1,8 @@ -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/BudgetedAiClassifierTests.cs b/src/core/tests/Deal.Tests.Unit/BudgetedAiClassifierTests.cs index c13a65e..86c5415 100644 --- a/src/core/tests/Deal.Tests.Unit/BudgetedAiClassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/BudgetedAiClassifierTests.cs @@ -3,9 +3,15 @@ using Deal.Contracts.Integrations.Models; using Deal.Infrastructure.Data; using Deal.Infrastructure.Integrations; using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants; using Microsoft.Extensions.Logging.Abstractions; diff --git a/src/core/tests/Deal.Tests.Unit/BudgetedAiToolsTests.cs b/src/core/tests/Deal.Tests.Unit/BudgetedAiToolsTests.cs index 8789b2f..037f58f 100644 --- a/src/core/tests/Deal.Tests.Unit/BudgetedAiToolsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/BudgetedAiToolsTests.cs @@ -2,8 +2,11 @@ using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; using Deal.Infrastructure.Data; using Deal.Infrastructure.Integrations; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants; using Microsoft.Extensions.Logging.Abstractions; diff --git a/src/core/tests/Deal.Tests.Unit/CardComposerTests.cs b/src/core/tests/Deal.Tests.Unit/CardComposerTests.cs index 839e27a..6165301 100644 --- a/src/core/tests/Deal.Tests.Unit/CardComposerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/CardComposerTests.cs @@ -1,9 +1,17 @@ using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Settings.Application; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/CardMoverTests.cs b/src/core/tests/Deal.Tests.Unit/CardMoverTests.cs index 7328da6..1db5aa9 100644 --- a/src/core/tests/Deal.Tests.Unit/CardMoverTests.cs +++ b/src/core/tests/Deal.Tests.Unit/CardMoverTests.cs @@ -1,91 +1,94 @@ -using Deal.Infrastructure.Services; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.Models; - -namespace Deal.Tests.Unit; - -/// -/// Тесты единой точки перехода карточки (R4 этапа 9): маршрутизация цели -/// «стадия Выбранных» → (история + сброс напоминания) и -/// «дашборд-контейнер» → (журнал/matchHits/обучение ML). -/// Нормализация результатов — к . -/// -/// -/// Фейки: (единые строки Cards), , -/// и — зависимости CardsService. -/// -public sealed class CardMoverTests -{ - private static readonly TransitionContext UserMove = new() { Actor = "user", Learn = true }; - - [Fact] - public async Task Move_ToStage_RoutesToStageMoveAndResetsReminder() - { - (CardMover mover, FakeKanjStore store) = Create(); - store.SeedCard(new CardDto - { - Id = "c_1", - Col = "inbox", - Title = "Заказ", - Reminder = new CardReminderDto(1000), - }); - - CardMoveResultDto result = await mover.MoveAsync("c_1", CardsDefaultContainers.Planned, UserMove, CancellationToken.None); - - Assert.Null(result.Error); - Assert.True(result.Exists); - CardDto card = Assert.Single(store.CardDtos); - Assert.Equal(CardsDefaultContainers.Planned, card.Col); - Assert.Null(card.Reminder); // move по стадии сбрасывает напоминание - CardHistoryDto entry = Assert.Single(card.History); - Assert.Equal(CardsDefaultContainers.Planned, entry.Stage); // запись истории о переносе - } - - [Fact] - public async Task Move_ToBoard_RoutesToDashboard() - { - (CardMover mover, FakeKanjStore store) = Create(); - store.SeedBoard(new ContainerDto { Id = "b_py", Name = "Python" }); - store.SeedCard(new CardDto { Id = "c_1", Col = KanbanColumns.Inbox, SourceMsg = "Нужен Python" }); - - CardMoveResultDto result = await mover.MoveAsync("c_1", "b_py", UserMove, CancellationToken.None); - - Assert.Null(result.Error); - Assert.True(result.Exists); - Assert.Equal("b_py", Assert.Single(store.CardDtos).Col); - Assert.Single(store.Moves); // журнал CardMoves дашборд-переноса - } - - [Fact] - public async Task Move_UnknownTarget_ReturnsDashboardInvalidTargetError() - { - (CardMover mover, FakeKanjStore store) = Create(); - store.SeedCard(new CardDto { Id = "c_1", Col = KanbanColumns.Inbox, SourceMsg = "Текст" }); - - CardMoveResultDto result = await mover.MoveAsync("c_1", "b_ghost", UserMove, CancellationToken.None); - - Assert.Equal(CardsService.MoveTargetInvalidDetail, result.Error); - Assert.True(result.Exists); // ошибка важнее признака наличия - } - - [Fact] - public async Task Move_ToStage_CardMissing_ReportsNotFound() - { - (CardMover mover, _) = Create(); - - CardMoveResultDto result = await mover.MoveAsync("c_ghost", CardsDefaultContainers.Planned, UserMove, CancellationToken.None); - - Assert.Null(result.Error); - Assert.False(result.Exists); - } - - private static (CardMover Mover, FakeKanjStore Store) Create() - { - var store = new FakeKanjStore(); - var cardsService = new CardsService(store, new FakeSettingsStore(), new FakeMlClient(), new FakeFileStorage()); - return (new CardMover(cardsService), store); - } -} +using Deal.Infrastructure.Services; +using Deal.Modules.Cards.Application.Abstractions; +using Deal.Modules.Cards.Application.Dtos; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; + +namespace Deal.Tests.Unit; + +/// +/// Тесты единой точки перехода карточки (R4 этапа 9): маршрутизация цели +/// «стадия Выбранных» → (история + сброс напоминания) и +/// «дашборд-контейнер» → (журнал/matchHits/обучение ML). +/// Нормализация результатов — к . +/// +/// +/// Фейки: (единые строки Cards), , +/// и — зависимости CardsService. +/// +public sealed class CardMoverTests +{ + private static readonly TransitionContext UserMove = new() { Actor = "user", Learn = true }; + + [Fact] + public async Task Move_ToStage_RoutesToStageMoveAndResetsReminder() + { + (CardMover mover, FakeKanjStore store) = Create(); + store.SeedCard(new CardDto + { + Id = "c_1", + Col = "inbox", + Title = "Заказ", + Reminder = new CardReminderDto(1000), + }); + + CardMoveResultDto result = await mover.MoveAsync("c_1", CardsDefaultContainers.Planned, UserMove, CancellationToken.None); + + Assert.Null(result.Error); + Assert.True(result.Exists); + CardDto card = Assert.Single(store.CardDtos); + Assert.Equal(CardsDefaultContainers.Planned, card.Col); + Assert.Null(card.Reminder); // move по стадии сбрасывает напоминание + CardHistoryDto entry = Assert.Single(card.History); + Assert.Equal(CardsDefaultContainers.Planned, entry.Stage); // запись истории о переносе + } + + [Fact] + public async Task Move_ToBoard_RoutesToDashboard() + { + (CardMover mover, FakeKanjStore store) = Create(); + store.SeedBoard(new ContainerDto { Id = "b_py", Name = "Python" }); + store.SeedCard(new CardDto { Id = "c_1", Col = KanbanColumns.Inbox, SourceMsg = "Нужен Python" }); + + CardMoveResultDto result = await mover.MoveAsync("c_1", "b_py", UserMove, CancellationToken.None); + + Assert.Null(result.Error); + Assert.True(result.Exists); + Assert.Equal("b_py", Assert.Single(store.CardDtos).Col); + Assert.Single(store.Moves); // журнал CardMoves дашборд-переноса + } + + [Fact] + public async Task Move_UnknownTarget_ReturnsDashboardInvalidTargetError() + { + (CardMover mover, FakeKanjStore store) = Create(); + store.SeedCard(new CardDto { Id = "c_1", Col = KanbanColumns.Inbox, SourceMsg = "Текст" }); + + CardMoveResultDto result = await mover.MoveAsync("c_1", "b_ghost", UserMove, CancellationToken.None); + + Assert.Equal(CardsService.MoveTargetInvalidDetail, result.Error); + Assert.True(result.Exists); // ошибка важнее признака наличия + } + + [Fact] + public async Task Move_ToStage_CardMissing_ReportsNotFound() + { + (CardMover mover, _) = Create(); + + CardMoveResultDto result = await mover.MoveAsync("c_ghost", CardsDefaultContainers.Planned, UserMove, CancellationToken.None); + + Assert.Null(result.Error); + Assert.False(result.Exists); + } + + private static (CardMover Mover, FakeKanjStore Store) Create() + { + var store = new FakeKanjStore(); + var cardsService = new CardsService(store, new FakeSettingsStore(), new FakeMlClient(), new FakeFileStorage()); + return (new CardMover(cardsService), store); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/CardReclassifierTests.cs b/src/core/tests/Deal.Tests.Unit/CardReclassifierTests.cs index 6851de6..d26a00b 100644 --- a/src/core/tests/Deal.Tests.Unit/CardReclassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/CardReclassifierTests.cs @@ -1,365 +1,372 @@ -using System.Text.Json; -using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Tests.Unit; - -/// -/// Тесты ручной переклассификации карточек — CardReclassifier (этап 12, пакет D; -/// прототип leads.py reclassify_lead L292–389 + reclassify_inbox L392–417). -/// -/// -/// Проверяется поведение без ИИ (детерминированный локальный разбор — «несанкционированный» сбой без кредов не -/// роняет проход), ИИ-путь (доска + обучающие сигналы ML), отсев спама/непройденного фильтра в корзину, -/// single-flight-замок и счётчики исхода. Всё — на in-memory фейках (FakeKanjStore/FakeMlClient/FakeAiClassifier). -/// -public sealed class CardReclassifierTests -{ - // Контекст теста: сервис поверх in-memory фейков. - private sealed record Context( - CardReclassifier Reclassifier, - FakeKanjStore Store, - FakeSettingsStore Settings, - FakeMlClient MlClient, - FakeAiClassifier AiClassifier, - ReclassifyGate Gate); - - // Собирает контекст: фейки хранилищ/портов, реальные композитор/парсер/сервис карточек. - // Возвращает: Сервис и фейки (сценарий до-настраивает карточки/доски/настройки). - private static Context CreateContext() - { - var settings = new FakeSettingsStore(); - var store = new FakeKanjStore(); - var mlClient = new FakeMlClient(); - var aiClassifier = new FakeAiClassifier(); - var fieldsParser = new LocalFieldsParser(settings); - var composer = new CardComposer(store, settings); - var cardsService = new CardsService(store, settings, mlClient, new FakeFileStorage()); - var gate = new ReclassifyGate(); - var reclassifier = new CardReclassifier( - store, settings, aiClassifier, fieldsParser, composer, cardsService, mlClient, gate); - return new Context(reclassifier, store, settings, mlClient, aiClassifier, gate); - } - - // Сериализует значение настройки в JSON (как пишет SettingsStore). - // value: Значение (число/булево/строка). - // Возвращает: JSON для Preload. - private static string Json(object value) => JsonSerializer.Serialize(value); - - // Карточка «Неразобранного» с исходным текстом (поля, нужные классификации). - // id: Id карточки. - // sourceMsg: Исходный текст. - // contact: Старый контакт карточки. - // receivedAtMs: Время получения (для порядка батча). - // Возвращает: Карточка как если бы была сохранена в БД. - private static CardDto Card(string id, string sourceMsg, string contact = "", long receivedAtMs = 1_700_000_000_000) => new() - { - Id = id, - Col = "inbox", - SourceMsg = sourceMsg, - Contact = contact, - ReceivedAtMs = receivedAtMs, - }; - - // Минимальный разбор классификатора (сценарий перекрывает нужные поля). - // title: Заголовок. - // isVacancy: Признак найма. - // isSpam: Вердикт «не заявка». - // board: Назначенная колонка. - // Возвращает: Разбор классификатора. - private static AiParsedCardDto Parsed(string title, bool isVacancy = false, bool isSpam = false, string? board = null) => new( - Title: title, - Company: null, - Format: null, - Task: null, - Requirements: null, - Plus: null, - Conditions: null, - Summary: null, - Stack: Array.Empty(), - Budget: null, - Contacts: Array.Empty(), - IsVacancy: isVacancy, - IsVacancyKnown: false, - IsSpam: isSpam, - Board: board); - - // Доска с опциональными правилами (null — «правил нет»; свободная колонка). - // id: Id доски. - // keywords: Ключевые слова правил (пусто — правил нет). - // Возвращает: Контейнер доски. - private static ContainerDto Board(string id, IReadOnlyList? keywords = null) => new() - { - Id = id, - Name = id, - Rules = keywords is null - ? null - : new ContainerRulesDto( - Mode: "any", - Direction: Array.Empty(), - Keywords: keywords, - Stack: Array.Empty(), - Grade: Array.Empty(), - Exclude: Array.Empty(), - Budget: null), - }; - - // ─── Путь без ИИ (local fallback) ────────────────────────────────────── - - [Fact] - public async Task ReclassifyInbox_AiDisabled_UsesLocalFallbackWithoutCallingPort() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); - ctx.Store.SeedCard(Card("c_1", "Нужен сильный Python-разработчик, проект на полгода, оплата 200000 руб")); - - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); - - Assert.True(result.Started); - Assert.False(result.Busy); - Assert.Equal(1, result.Attempted); - Assert.Equal(1, result.Reclassified); - Assert.Equal(1, result.Kept); - Assert.Equal(0, result.Moved); - Assert.Equal(0, result.Trashed); - Assert.Equal(0, result.Skipped); - Assert.False(result.UsedAi); - Assert.Null(result.Reason); - - // ИИ-порт не звался вовсе; локальный разбор не подтверждает тип (маркерная гипотеза). - Assert.Equal(0, ctx.AiClassifier.FilterCalls); - Assert.Equal(0, ctx.AiClassifier.ClassifyCalls); - CardDto card = Assert.Single(ctx.Store.CardDtos); - Assert.Equal("inbox", card.Col); - Assert.True(card.IsNew); - Assert.False(card.IsVacancyKnown); - Assert.Empty(ctx.MlClient.Pushed); - } - - [Fact] - public async Task ReclassifyInbox_AiEnabledButClassifierFails_FallsBackToLocal() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(true)); - ctx.AiClassifier.ClassifyThrows = true; - ctx.Store.SeedCard(Card("c_1", "Нужен сильный Python-разработчик, проект на полгода, оплата 200000 руб")); - - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); - - Assert.True(result.Started); - Assert.Equal(1, result.Kept); - Assert.False(result.UsedAi); // сбой ИИ → локальный разбор, проход не падает - Assert.Equal(1, ctx.AiClassifier.ClassifyCalls); - Assert.Equal("inbox", Assert.Single(ctx.Store.CardDtos).Col); - } - - // ─── ИИ-путь: доска и обучающие сигналы ──────────────────────────────── - - [Fact] - public async Task ReclassifyInbox_AiAssignsFreeBoard_MovesCardAndPushesSignals() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(true)); - ctx.Store.SeedBoard(Board("b_py")); - const string text = "Ищу Python-разработчика на проект, оплата 250000 руб, старт сразу"; - ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик", isVacancy: true, board: "b_py"); - ctx.Store.SeedCard(Card("c_1", text)); - - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); - - Assert.True(result.UsedAi); - Assert.Equal(1, result.Moved); - Assert.Equal(0, result.Kept); - Assert.Equal(1, result.Attempted); - - CardDto card = Assert.Single(ctx.Store.CardDtos); - Assert.Equal("b_py", card.Col); - Assert.True(card.IsNew); - Assert.True(card.IsVacancy); - Assert.True(card.IsVacancyKnown); // успешная классификация ИИ подтверждает тип - - // Обучающие сигналы: доска (свободная колонка) и тип — вес гипотезы ИИ 0.4. - Assert.Contains(ctx.MlClient.Pushed, p => p.Label == "b_py" && p.Delta == 0.4); - Assert.Contains(ctx.MlClient.Pushed, p => p.Label == "t:hire" && p.Delta == 0.4); - } - - [Fact] - public async Task ReclassifyInbox_AiBoardWithRules_ComputesMatchHits() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(true)); - ctx.Store.SeedBoard(Board("b_py", keywords: ["python"])); - ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик", board: "b_py"); - ctx.Store.SeedCard(Card("c_1", "Срочно нужен python разработчик на длительный проект")); - - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); - - Assert.Equal(1, result.Moved); - CardDto card = Assert.Single(ctx.Store.CardDtos); - Assert.Equal("b_py", card.Col); - Assert.NotEmpty(card.MatchHits); // пересчитаны правила доски (Ruling 2) - } - - [Fact] - public async Task ReclassifyInbox_AiAssignsUnknownBoard_KeepsCardInInbox() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(true)); - // Доска не создана: ContainerAccepts-страховка не даёт положить карточку в несуществующую колонку. - ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик", board: "b_ghost"); - ctx.Store.SeedCard(Card("c_1", "Нужен python разработчик, проект на полгода, оплата достойная")); - - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); - - Assert.Equal(1, result.Kept); - Assert.Equal("inbox", Assert.Single(ctx.Store.CardDtos).Col); - } - - // ─── Спам / фильтр → корзина ─────────────────────────────────────────── - - [Fact] - public async Task ReclassifyInbox_AiMarksSpam_TrashesCardAndPushesSpamSignal() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(true)); - const string text = "Реклама: продвижение каналов и чатов, взаимный пиар, без бюджета"; - ctx.AiClassifier.ClassifyResult = Parsed("Реклама", isSpam: true); - ctx.Store.SeedCard(Card("c_1", text)); - - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); - - Assert.True(result.UsedAi); - Assert.Equal(1, result.Trashed); - Assert.Equal(0, result.Moved); - Assert.Equal(0, result.Kept); - - CardDto card = Assert.Single(ctx.Store.CardDtos); - Assert.Equal("trash", card.Col); - Assert.False(card.IsNew); - Assert.Contains(ctx.MlClient.Pushed, p => p.Label == "spam" && p.Delta == 0.4); - Assert.Contains(ctx.Store.Moves, m => m.Action == "trash"); // журнал переноса пишется - } - - [Fact] - public async Task ReclassifyInbox_AiFilterRejects_TrashesWithoutClassification() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(true)); - ctx.AiClassifier.FilterResult = new AiFilterResultDto(Pass: false, Reason: "не относится к интересам", Skipped: false); - ctx.Store.SeedCard(Card("c_1", "Служебное сообщение, не заявка, но длиной больше двадцати четырёх")); - - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); - - Assert.Equal(1, result.Trashed); - Assert.Equal(0, ctx.AiClassifier.ClassifyCalls); // фильтр заблокировал классификацию - Assert.Equal("trash", Assert.Single(ctx.Store.CardDtos).Col); - Assert.Contains(ctx.MlClient.Pushed, p => p.Label == "spam" && p.Delta == 0.4); - } - - // ─── Счётчики/отбор ──────────────────────────────────────────────────── - - [Fact] - public async Task ReclassifyInbox_EmptyInbox_ReturnsReasonWithoutStart() - { - Context ctx = CreateContext(); - - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); - - Assert.False(result.Started); - Assert.False(result.Busy); - Assert.Equal(0, result.Attempted); - Assert.Equal(CardReclassifier.EmptyInboxReason, result.Reason); - } - - [Fact] - public async Task ReclassifyInbox_IdFilter_LimitsTarget() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); - ctx.Store.SeedCard(Card("c_1", "Нужен Python-разработчик, проект на полгода, оплата 200000 руб", receivedAtMs: 2)); - ctx.Store.SeedCard(Card("c_2", "Нужен Java-разработчик, проект на год, оплата 300000 руб", receivedAtMs: 1)); - - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(["c_2"], CancellationToken.None); - - Assert.Equal(1, result.Attempted); - Assert.Equal(1, result.Kept); - CardDto card = Assert.Single(ctx.Store.CardDtos, c => c.Id == "c_2"); - Assert.True(card.IsNew); // переклассифицирована - CardDto untouched = Assert.Single(ctx.Store.CardDtos, c => c.Id == "c_1"); - Assert.False(untouched.IsNew); // вне ids - } - - [Fact] - public async Task ReclassifyInbox_SkipsCardWithoutSourceText() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); - ctx.Store.SeedCard(Card("c_empty", "")); - ctx.Store.SeedCard(Card("c_1", "Нужен Python-разработчик, проект на полгода, оплата 200000 руб", receivedAtMs: 1)); - - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); - - Assert.True(result.Started); - Assert.Equal(2, result.Attempted); - Assert.Equal(1, result.Skipped); - Assert.Equal(1, result.Reclassified); - } - - // ─── Одна карточка и замок ───────────────────────────────────────────── - - [Fact] - public async Task ReclassifyCard_NoSourceText_ReturnsReason() - { - Context ctx = CreateContext(); - ctx.Store.SeedCard(Card("c_1", "")); - CardDto card = ctx.Store.CardDtos.Single(); - - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyCardAsync(card, CancellationToken.None); - - Assert.False(result.Started); - Assert.Equal(1, result.Attempted); - Assert.Equal(1, result.Skipped); - Assert.Equal(CardReclassifier.NoSourceTextReason, result.Reason); - } - - [Fact] - public async Task ReclassifyCard_KeepsValidOldContactWhenNewIsEmpty() - { - Context ctx = CreateContext(); - ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); - ctx.Store.SeedCard(Card("c_1", "Нужен Python-разработчик, проект на полгода, оплата 200000 руб", contact: "@old_user")); - CardDto card = ctx.Store.CardDtos.Single(); - - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyCardAsync(card, CancellationToken.None); - - Assert.Equal(1, result.Kept); - Assert.Equal("@old_user", Assert.Single(ctx.Store.CardDtos).Contact); - } - - [Fact] - public async Task Reclassify_Busy_WhenAnotherPassHoldsGate() - { - Context ctx = CreateContext(); - ctx.Store.SeedCard(Card("c_1", "Нужен Python-разработчик, проект на полгода, оплата 200000 руб")); - Assert.True(ctx.Gate.TryEnter()); - - try - { - ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); - - Assert.True(result.Busy); - Assert.False(result.Started); - Assert.Equal(0, result.Attempted); - } - finally - { - ctx.Gate.Exit(); - } - } -} +using System.Text.Json; +using Deal.Contracts.Integrations.Models; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Pipeline.Application.Parse; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; + +namespace Deal.Tests.Unit; + +/// +/// Тесты ручной переклассификации карточек — CardReclassifier (этап 12, пакет D; +/// прототип leads.py reclassify_lead L292–389 + reclassify_inbox L392–417). +/// +/// +/// Проверяется поведение без ИИ (детерминированный локальный разбор — «несанкционированный» сбой без кредов не +/// роняет проход), ИИ-путь (доска + обучающие сигналы ML), отсев спама/непройденного фильтра в корзину, +/// single-flight-замок и счётчики исхода. Всё — на in-memory фейках (FakeKanjStore/FakeMlClient/FakeAiClassifier). +/// +public sealed class CardReclassifierTests +{ + // Контекст теста: сервис поверх in-memory фейков. + private sealed record Context( + CardReclassifier Reclassifier, + FakeKanjStore Store, + FakeSettingsStore Settings, + FakeMlClient MlClient, + FakeAiClassifier AiClassifier, + ReclassifyGate Gate); + + // Собирает контекст: фейки хранилищ/портов, реальные композитор/парсер/сервис карточек. + // Возвращает: Сервис и фейки (сценарий до-настраивает карточки/доски/настройки). + private static Context CreateContext() + { + var settings = new FakeSettingsStore(); + var store = new FakeKanjStore(); + var mlClient = new FakeMlClient(); + var aiClassifier = new FakeAiClassifier(); + var fieldsParser = new LocalFieldsParser(settings); + var composer = new CardComposer(store, settings); + var cardsService = new CardsService(store, settings, mlClient, new FakeFileStorage()); + var gate = new ReclassifyGate(); + var reclassifier = new CardReclassifier( + store, settings, aiClassifier, fieldsParser, composer, cardsService, mlClient, gate); + return new Context(reclassifier, store, settings, mlClient, aiClassifier, gate); + } + + // Сериализует значение настройки в JSON (как пишет SettingsStore). + // value: Значение (число/булево/строка). + // Возвращает: JSON для Preload. + private static string Json(object value) => JsonSerializer.Serialize(value); + + // Карточка «Неразобранного» с исходным текстом (поля, нужные классификации). + // id: Id карточки. + // sourceMsg: Исходный текст. + // contact: Старый контакт карточки. + // receivedAtMs: Время получения (для порядка батча). + // Возвращает: Карточка как если бы была сохранена в БД. + private static CardDto Card(string id, string sourceMsg, string contact = "", long receivedAtMs = 1_700_000_000_000) => new() + { + Id = id, + Col = "inbox", + SourceMsg = sourceMsg, + Contact = contact, + ReceivedAtMs = receivedAtMs, + }; + + // Минимальный разбор классификатора (сценарий перекрывает нужные поля). + // title: Заголовок. + // isVacancy: Признак найма. + // isSpam: Вердикт «не заявка». + // board: Назначенная колонка. + // Возвращает: Разбор классификатора. + private static AiParsedCardDto Parsed(string title, bool isVacancy = false, bool isSpam = false, string? board = null) => new( + Title: title, + Company: null, + Format: null, + Task: null, + Requirements: null, + Plus: null, + Conditions: null, + Summary: null, + Stack: Array.Empty(), + Budget: null, + Contacts: Array.Empty(), + IsVacancy: isVacancy, + IsVacancyKnown: false, + IsSpam: isSpam, + Board: board); + + // Доска с опциональными правилами (null — «правил нет»; свободная колонка). + // id: Id доски. + // keywords: Ключевые слова правил (пусто — правил нет). + // Возвращает: Контейнер доски. + private static ContainerDto Board(string id, IReadOnlyList? keywords = null) => new() + { + Id = id, + Name = id, + Rules = keywords is null + ? null + : new ContainerRulesDto( + Mode: "any", + Direction: Array.Empty(), + Keywords: keywords, + Stack: Array.Empty(), + Grade: Array.Empty(), + Exclude: Array.Empty(), + Budget: null), + }; + + // ─── Путь без ИИ (local fallback) ────────────────────────────────────── + + [Fact] + public async Task ReclassifyInbox_AiDisabled_UsesLocalFallbackWithoutCallingPort() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); + ctx.Store.SeedCard(Card("c_1", "Нужен сильный Python-разработчик, проект на полгода, оплата 200000 руб")); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); + + Assert.True(result.Started); + Assert.False(result.Busy); + Assert.Equal(1, result.Attempted); + Assert.Equal(1, result.Reclassified); + Assert.Equal(1, result.Kept); + Assert.Equal(0, result.Moved); + Assert.Equal(0, result.Trashed); + Assert.Equal(0, result.Skipped); + Assert.False(result.UsedAi); + Assert.Null(result.Reason); + + // ИИ-порт не звался вовсе; локальный разбор не подтверждает тип (маркерная гипотеза). + Assert.Equal(0, ctx.AiClassifier.FilterCalls); + Assert.Equal(0, ctx.AiClassifier.ClassifyCalls); + CardDto card = Assert.Single(ctx.Store.CardDtos); + Assert.Equal("inbox", card.Col); + Assert.True(card.IsNew); + Assert.False(card.IsVacancyKnown); + Assert.Empty(ctx.MlClient.Pushed); + } + + [Fact] + public async Task ReclassifyInbox_AiEnabledButClassifierFails_FallsBackToLocal() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(true)); + ctx.AiClassifier.ClassifyThrows = true; + ctx.Store.SeedCard(Card("c_1", "Нужен сильный Python-разработчик, проект на полгода, оплата 200000 руб")); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); + + Assert.True(result.Started); + Assert.Equal(1, result.Kept); + Assert.False(result.UsedAi); // сбой ИИ → локальный разбор, проход не падает + Assert.Equal(1, ctx.AiClassifier.ClassifyCalls); + Assert.Equal("inbox", Assert.Single(ctx.Store.CardDtos).Col); + } + + // ─── ИИ-путь: доска и обучающие сигналы ──────────────────────────────── + + [Fact] + public async Task ReclassifyInbox_AiAssignsFreeBoard_MovesCardAndPushesSignals() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(true)); + ctx.Store.SeedBoard(Board("b_py")); + const string text = "Ищу Python-разработчика на проект, оплата 250000 руб, старт сразу"; + ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик", isVacancy: true, board: "b_py"); + ctx.Store.SeedCard(Card("c_1", text)); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); + + Assert.True(result.UsedAi); + Assert.Equal(1, result.Moved); + Assert.Equal(0, result.Kept); + Assert.Equal(1, result.Attempted); + + CardDto card = Assert.Single(ctx.Store.CardDtos); + Assert.Equal("b_py", card.Col); + Assert.True(card.IsNew); + Assert.True(card.IsVacancy); + Assert.True(card.IsVacancyKnown); // успешная классификация ИИ подтверждает тип + + // Обучающие сигналы: доска (свободная колонка) и тип — вес гипотезы ИИ 0.4. + Assert.Contains(ctx.MlClient.Pushed, p => p.Label == "b_py" && p.Delta == 0.4); + Assert.Contains(ctx.MlClient.Pushed, p => p.Label == "t:hire" && p.Delta == 0.4); + } + + [Fact] + public async Task ReclassifyInbox_AiBoardWithRules_ComputesMatchHits() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(true)); + ctx.Store.SeedBoard(Board("b_py", keywords: ["python"])); + ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик", board: "b_py"); + ctx.Store.SeedCard(Card("c_1", "Срочно нужен python разработчик на длительный проект")); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); + + Assert.Equal(1, result.Moved); + CardDto card = Assert.Single(ctx.Store.CardDtos); + Assert.Equal("b_py", card.Col); + Assert.NotEmpty(card.MatchHits); // пересчитаны правила доски (Ruling 2) + } + + [Fact] + public async Task ReclassifyInbox_AiAssignsUnknownBoard_KeepsCardInInbox() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(true)); + // Доска не создана: ContainerAccepts-страховка не даёт положить карточку в несуществующую колонку. + ctx.AiClassifier.ClassifyResult = Parsed("Python-разработчик", board: "b_ghost"); + ctx.Store.SeedCard(Card("c_1", "Нужен python разработчик, проект на полгода, оплата достойная")); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); + + Assert.Equal(1, result.Kept); + Assert.Equal("inbox", Assert.Single(ctx.Store.CardDtos).Col); + } + + // ─── Спам / фильтр → корзина ─────────────────────────────────────────── + + [Fact] + public async Task ReclassifyInbox_AiMarksSpam_TrashesCardAndPushesSpamSignal() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(true)); + const string text = "Реклама: продвижение каналов и чатов, взаимный пиар, без бюджета"; + ctx.AiClassifier.ClassifyResult = Parsed("Реклама", isSpam: true); + ctx.Store.SeedCard(Card("c_1", text)); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); + + Assert.True(result.UsedAi); + Assert.Equal(1, result.Trashed); + Assert.Equal(0, result.Moved); + Assert.Equal(0, result.Kept); + + CardDto card = Assert.Single(ctx.Store.CardDtos); + Assert.Equal("trash", card.Col); + Assert.False(card.IsNew); + Assert.Contains(ctx.MlClient.Pushed, p => p.Label == "spam" && p.Delta == 0.4); + Assert.Contains(ctx.Store.Moves, m => m.Action == "trash"); // журнал переноса пишется + } + + [Fact] + public async Task ReclassifyInbox_AiFilterRejects_TrashesWithoutClassification() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(true)); + ctx.AiClassifier.FilterResult = new AiFilterResultDto(Pass: false, Reason: "не относится к интересам", Skipped: false); + ctx.Store.SeedCard(Card("c_1", "Служебное сообщение, не заявка, но длиной больше двадцати четырёх")); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); + + Assert.Equal(1, result.Trashed); + Assert.Equal(0, ctx.AiClassifier.ClassifyCalls); // фильтр заблокировал классификацию + Assert.Equal("trash", Assert.Single(ctx.Store.CardDtos).Col); + Assert.Contains(ctx.MlClient.Pushed, p => p.Label == "spam" && p.Delta == 0.4); + } + + // ─── Счётчики/отбор ──────────────────────────────────────────────────── + + [Fact] + public async Task ReclassifyInbox_EmptyInbox_ReturnsReasonWithoutStart() + { + Context ctx = CreateContext(); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); + + Assert.False(result.Started); + Assert.False(result.Busy); + Assert.Equal(0, result.Attempted); + Assert.Equal(CardReclassifier.EmptyInboxReason, result.Reason); + } + + [Fact] + public async Task ReclassifyInbox_IdFilter_LimitsTarget() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); + ctx.Store.SeedCard(Card("c_1", "Нужен Python-разработчик, проект на полгода, оплата 200000 руб", receivedAtMs: 2)); + ctx.Store.SeedCard(Card("c_2", "Нужен Java-разработчик, проект на год, оплата 300000 руб", receivedAtMs: 1)); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(["c_2"], CancellationToken.None); + + Assert.Equal(1, result.Attempted); + Assert.Equal(1, result.Kept); + CardDto card = Assert.Single(ctx.Store.CardDtos, c => c.Id == "c_2"); + Assert.True(card.IsNew); // переклассифицирована + CardDto untouched = Assert.Single(ctx.Store.CardDtos, c => c.Id == "c_1"); + Assert.False(untouched.IsNew); // вне ids + } + + [Fact] + public async Task ReclassifyInbox_SkipsCardWithoutSourceText() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); + ctx.Store.SeedCard(Card("c_empty", "")); + ctx.Store.SeedCard(Card("c_1", "Нужен Python-разработчик, проект на полгода, оплата 200000 руб", receivedAtMs: 1)); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); + + Assert.True(result.Started); + Assert.Equal(2, result.Attempted); + Assert.Equal(1, result.Skipped); + Assert.Equal(1, result.Reclassified); + } + + // ─── Одна карточка и замок ───────────────────────────────────────────── + + [Fact] + public async Task ReclassifyCard_NoSourceText_ReturnsReason() + { + Context ctx = CreateContext(); + ctx.Store.SeedCard(Card("c_1", "")); + CardDto card = ctx.Store.CardDtos.Single(); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyCardAsync(card, CancellationToken.None); + + Assert.False(result.Started); + Assert.Equal(1, result.Attempted); + Assert.Equal(1, result.Skipped); + Assert.Equal(CardReclassifier.NoSourceTextReason, result.Reason); + } + + [Fact] + public async Task ReclassifyCard_KeepsValidOldContactWhenNewIsEmpty() + { + Context ctx = CreateContext(); + ctx.Settings.Preload(SettingsKeys.AiEnabled, Json(false)); + ctx.Store.SeedCard(Card("c_1", "Нужен Python-разработчик, проект на полгода, оплата 200000 руб", contact: "@old_user")); + CardDto card = ctx.Store.CardDtos.Single(); + + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyCardAsync(card, CancellationToken.None); + + Assert.Equal(1, result.Kept); + Assert.Equal("@old_user", Assert.Single(ctx.Store.CardDtos).Contact); + } + + [Fact] + public async Task Reclassify_Busy_WhenAnotherPassHoldsGate() + { + Context ctx = CreateContext(); + ctx.Store.SeedCard(Card("c_1", "Нужен Python-разработчик, проект на полгода, оплата 200000 руб")); + Assert.True(ctx.Gate.TryEnter()); + + try + { + ReclassifyResultDto result = await ctx.Reclassifier.ReclassifyInboxAsync(null, CancellationToken.None); + + Assert.True(result.Busy); + Assert.False(result.Started); + Assert.Equal(0, result.Attempted); + } + finally + { + ctx.Gate.Exit(); + } + } +} diff --git a/src/core/tests/Deal.Tests.Unit/CardsServiceFilesTests.cs b/src/core/tests/Deal.Tests.Unit/CardsServiceFilesTests.cs index 72f5957..b83ae8d 100644 --- a/src/core/tests/Deal.Tests.Unit/CardsServiceFilesTests.cs +++ b/src/core/tests/Deal.Tests.Unit/CardsServiceFilesTests.cs @@ -1,269 +1,272 @@ -using System.Text; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.Models; - -namespace Deal.Tests.Unit; - -/// -/// Тесты вложений карточки — (единый домен карточки, этап 9): add (детект -/// kind, objectKey-форма, мета в FilesJson, порядок файлов), get-entry для download, remove (объект + мета), -/// 404-семантика и отсутствие записи объекта на несуществующей карточке (files.py L57–94; Ruling 4/11). -/// -/// -/// Зависимости — фейки (строки Cards) и -/// (in-memory IFileStorage, 1:1 с контрактом порта: Put с позиции 0). -/// Мета записи — wire-форма {id pf_, name, size, kind, label, objectKey}; objectKey — -/// projects/{card}/{fileId}_{ms}_{safeName}. -/// -public sealed class CardsServiceFilesTests -{ - // ─── Add (files.py add_file L57–75) ───────────────────────────────────── - - [Fact] - public async Task Add_ImageMime_DetectsKindByMimeWritesObjectAndMeta() - { - (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); - store.SeedCard(Card("c_1", updatedAtMs: 1)); - byte[] content = Encoding.UTF8.GetBytes("данные-картинки"); - using MemoryStream stream = new(content); - - CardFileDto? entry = await service.AddFileAsync( - "c_1", "photo.bin", "image/png", stream, content.LongLength, CancellationToken.None); - - Assert.NotNull(entry); - Assert.StartsWith("pf_", entry!.Id); // id записи файла - Assert.Equal("photo.bin", entry.Name); // name — как прислано - Assert.Equal(content.LongLength, entry.Size); // size — длина содержимого - Assert.Equal("image", entry.Kind); // детект по MIME-префиксу image/ - Assert.Equal("Изображение", entry.Label); - // objectKey-форма: projects/{card}/{fileId}_{ms}_{safeName} — id записи делает ключ уникальным. - Assert.Matches("^projects/c_1/pf_[0-9a-f]{12}_\\d+_photo\\.bin$", entry.ObjectKey); - Assert.StartsWith($"projects/c_1/{entry.Id}_", entry.ObjectKey); // в ключ входит id этой записи - Assert.Equal(content, storage.ContentOf(entry.ObjectKey)); // объект записан в хранилище - CardDto card = Assert.Single(store.CardDtos); - Assert.Equal(entry, Assert.Single(card.Files)); // мета дописана в FilesJson карточки - Assert.True(card.UpdatedAtMs > 1); // атомарный AddFileAsync бампает UpdatedAt - } - - [Fact] - public async Task Add_PdfExtensionWithoutMime_DetectsKindByExtension() - { - (CardsService service, FakeKanjStore store, _) = Create(); - store.SeedCard(Card("c_1")); - - CardFileDto? entry = await service.AddFileAsync( - "c_1", "tz.pdf", contentType: null, new MemoryStream("тз"u8.ToArray()), size: 2, CancellationToken.None); - - Assert.NotNull(entry); - Assert.Equal("document", entry!.Kind); // без MIME — расширение по наборам files.py KIND_BY_EXT - Assert.Equal("Документ", entry.Label); - Assert.Equal("tz.pdf", entry.Name); - } - - [Fact] - public async Task Add_TwoFiles_AppendsPreservingOrderAndBothObjects() - { - (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); - store.SeedCard(Card("c_1")); - - CardFileDto? first = await service.AddFileAsync( - "c_1", "a.txt", "text/plain", new MemoryStream("aaa"u8.ToArray()), 3, CancellationToken.None); - CardFileDto? second = await service.AddFileAsync( - "c_1", "b.png", "image/png", new MemoryStream("bbb"u8.ToArray()), 3, CancellationToken.None); - - Assert.NotNull(first); - Assert.NotNull(second); - CardDto card = Assert.Single(store.CardDtos); - Assert.Equal(new[] { first, second }, card.Files); // порядок добавления сохраняется (append в конец) - Assert.Equal(2, storage.StoredObjectKeys.Count); // оба объекта сохранены - Assert.Equal(2, card.Files.Select(file => file.ObjectKey).Distinct().Count()); - } - - [Fact] - public async Task Add_CardMissing_ReturnsNullAndDoesNotWriteObject() - { - (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); - - CardFileDto? entry = await 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); - } - - [Fact] - public async Task Add_EmptyOrWhitespaceFileName_DefaultsToPrototypeFile() - { - (CardsService service, FakeKanjStore store, _) = Create(); - store.SeedCard(Card("c_1")); - - CardFileDto? entry = await service.AddFileAsync( - "c_1", " ", contentType: null, new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None); - - // 1:1 прототип: «f.filename or "file"» — пустое имя не ошибка, а дефолт «file». - Assert.NotNull(entry); - Assert.Equal("file", entry!.Name); - Assert.Equal("other", entry.Kind); // расширения нет — other/«Файл» - Assert.Equal("Файл", entry.Label); - Assert.Matches("^projects/c_1/pf_[0-9a-f]{12}_\\d+_file$", entry.ObjectKey); - } - - [Fact] - public async Task Add_FileNameWithPathAndQuoteChars_SanitizesObjectKeyButKeepsMetaName() - { - (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); - store.SeedCard(Card("c_1")); - string rawName = "..\\файл\"отчёта v2.pdf"; - - CardFileDto? entry = await service.AddFileAsync( - "c_1", rawName, contentType: null, new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None); - - Assert.NotNull(entry); - Assert.Equal(rawName, entry!.Name); // в метаданных имя остаётся как прислано - // В objectKey path-разделители и кавычки заменяются «_» — у ключа нет лишних сегментов пути. - Assert.Matches("^projects/c_1/pf_[0-9a-f]{12}_\\d+_\\.\\._файл_отчёта v2\\.pdf$", entry.ObjectKey); - Assert.NotNull(storage.ContentOf(entry.ObjectKey)); - Assert.Single(store.CardDtos); - } - - [Fact] - public async Task Add_StreamPositionNotZero_StoresWholeContent() - { - (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); - store.SeedCard(Card("c_1")); - byte[] content = Encoding.UTF8.GetBytes("полное-содержимое-файла"); - using MemoryStream stream = new(content) { Position = 5 }; // эндпоинт мог прочитать поток раньше - - CardFileDto? entry = await service.AddFileAsync( - "c_1", "doc.txt", "text/plain", stream, content.LongLength, CancellationToken.None); - - // Контракт порта: Put читает содержимое с позиции 0 — в хранилище весь файл, не хвост. - Assert.NotNull(entry); - Assert.Equal(content.LongLength, entry!.Size); - Assert.Equal(content, storage.ContentOf(entry.ObjectKey)); - } - - // ─── GetEntry (files.py get_file_entry L78–83; download-эндпоинт) ──────── - - [Fact] - public async Task GetEntry_ExistingFile_ReturnsEntryMeta() - { - (CardsService service, FakeKanjStore store, _) = Create(); - var file = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000000_tz.pdf"); - store.SeedCard(Card("c_1") with { Files = new[] { file } }); - - CardFileDto? entry = await service.GetFileEntryAsync("c_1", "pf_1", CancellationToken.None); - - Assert.NotNull(entry); - Assert.Equal(file, entry); // мета для download: id/name/size/kind/label/objectKey - } - - [Fact] - public async Task GetEntry_CardMissing_ReturnsNull() - { - (CardsService service, _, _) = Create(); - - CardFileDto? entry = await service.GetFileEntryAsync("c_missing", "pf_1", CancellationToken.None); - - Assert.Null(entry); // 404 «Карточка не найдена» у эндпоинта - } - - [Fact] - public async Task GetEntry_FileNotInMetadata_ReturnsNull() - { - (CardsService service, FakeKanjStore 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-семантика - } - - // ─── Remove (files.py remove_file L86–94) ─────────────────────────────── - - [Fact] - public async Task Remove_Existing_DeletesObjectRemovesMetaAndReturnsCard() - { - (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); - var first = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000001_tz.pdf"); - var second = new CardFileDto("pf_2", "photo.png", 200, "image", "Изображение", "projects/c_1/1710000000002_photo.png"); - store.SeedCard(Card("c_1", updatedAtMs: 1) with { Files = new[] { first, second } }); - await storage.PutAsync(first.ObjectKey, new MemoryStream("t"u8.ToArray()), "application/pdf", CancellationToken.None); - await storage.PutAsync(second.ObjectKey, new MemoryStream("p"u8.ToArray()), "image/png", CancellationToken.None); - - CardDto? card = await service.RemoveFileAsync("c_1", "pf_1", CancellationToken.None); - - Assert.NotNull(card); - Assert.Equal("pf_2", Assert.Single(card!.Files).Id); // из мета убрана только указанная запись - Assert.Equal(new[] { first.ObjectKey }, storage.DeletedKeys); // объект удалён из хранилища - Assert.Null(storage.ContentOf(first.ObjectKey)); - Assert.NotNull(storage.ContentOf(second.ObjectKey)); // объект соседней записи не тронут - CardDto stored = Assert.Single(store.CardDtos); - Assert.Equal("pf_2", Assert.Single(stored.Files).Id); - Assert.True(stored.UpdatedAtMs > 1); // удаление бампает UpdatedAt - } - - [Fact] - public async Task Remove_UnknownFileId_ReturnsCardUnchangedWithoutStorageDelete() - { - (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); - var file = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000001_tz.pdf"); - store.SeedCard(Card("c_1") with { Files = new[] { file } }); - - CardDto? card = await service.RemoveFileAsync("c_1", "pf_ghost", CancellationToken.None); - - // Как remove_link: записи нет — список остаётся прежним, ошибки НЕТ. - Assert.NotNull(card); - Assert.Equal("pf_1", Assert.Single(card!.Files).Id); - Assert.Empty(storage.DeletedKeys); // объект не удалялся (записи с таким id нет) - } - - [Fact] - public async Task Remove_EntryWithEmptyObjectKey_SkipsStorageDelete() - { - (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); - store.SeedCard(Card("c_1") - with { Files = new[] { new CardFileDto("pf_mock", "meta-only.pdf", 10, "document", "Документ", ObjectKey: string.Empty) } }); - - CardDto? card = await service.RemoveFileAsync("c_1", "pf_mock", CancellationToken.None); - - // 1:1 remove_file («entry and entry.get("objectKey")»): у записи-мока пустой ключ — удалять нечего. - Assert.NotNull(card); - Assert.Empty(card!.Files); - Assert.Empty(storage.DeletedKeys); - } - - [Fact] - public async Task Remove_CardMissing_ReturnsNullWithoutStorageDelete() - { - (CardsService service, _, FakeFileStorage storage) = Create(); - - CardDto? card = await service.RemoveFileAsync("c_missing", "pf_1", CancellationToken.None); - - Assert.Null(card); // эндпоинт отвечает 404 «Карточка не найдена» - Assert.Empty(storage.DeletedKeys); - } - - // ─── Хелперы ────────────────────────────────────────────────────────── - - private static (CardsService Service, FakeKanjStore Store, FakeFileStorage Storage) Create() - { - var store = new FakeKanjStore(); - var storage = new FakeFileStorage(); - return (new CardsService(store, new FakeSettingsStore(), new FakeMlClient(), storage), store, storage); - } - - // Карточка с полями по умолчанию (planned, CreatedAtMs=1, UpdatedAtMs=1; Files пуст). - private static CardDto Card(string id, long updatedAtMs = 1) - { - return new CardDto - { - Id = id, - Col = "planned", - CreatedAtMs = 1, - UpdatedAtMs = updatedAtMs, - }; - } -} +using System.Text; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; + +namespace Deal.Tests.Unit; + +/// +/// Тесты вложений карточки — (единый домен карточки, этап 9): add (детект +/// kind, objectKey-форма, мета в FilesJson, порядок файлов), get-entry для download, remove (объект + мета), +/// 404-семантика и отсутствие записи объекта на несуществующей карточке (files.py L57–94; Ruling 4/11). +/// +/// +/// Зависимости — фейки (строки Cards) и +/// (in-memory IFileStorage, 1:1 с контрактом порта: Put с позиции 0). +/// Мета записи — wire-форма {id pf_, name, size, kind, label, objectKey}; objectKey — +/// projects/{card}/{fileId}_{ms}_{safeName}. +/// +public sealed class CardsServiceFilesTests +{ + // ─── Add (files.py add_file L57–75) ───────────────────────────────────── + + [Fact] + public async Task Add_ImageMime_DetectsKindByMimeWritesObjectAndMeta() + { + (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); + store.SeedCard(Card("c_1", updatedAtMs: 1)); + byte[] content = Encoding.UTF8.GetBytes("данные-картинки"); + using MemoryStream stream = new(content); + + CardFileDto? entry = await service.AddFileAsync( + "c_1", "photo.bin", "image/png", stream, content.LongLength, CancellationToken.None); + + Assert.NotNull(entry); + Assert.StartsWith("pf_", entry!.Id); // id записи файла + Assert.Equal("photo.bin", entry.Name); // name — как прислано + Assert.Equal(content.LongLength, entry.Size); // size — длина содержимого + Assert.Equal("image", entry.Kind); // детект по MIME-префиксу image/ + Assert.Equal("Изображение", entry.Label); + // objectKey-форма: projects/{card}/{fileId}_{ms}_{safeName} — id записи делает ключ уникальным. + Assert.Matches("^projects/c_1/pf_[0-9a-f]{12}_\\d+_photo\\.bin$", entry.ObjectKey); + Assert.StartsWith($"projects/c_1/{entry.Id}_", entry.ObjectKey); // в ключ входит id этой записи + Assert.Equal(content, storage.ContentOf(entry.ObjectKey)); // объект записан в хранилище + CardDto card = Assert.Single(store.CardDtos); + Assert.Equal(entry, Assert.Single(card.Files)); // мета дописана в FilesJson карточки + Assert.True(card.UpdatedAtMs > 1); // атомарный AddFileAsync бампает UpdatedAt + } + + [Fact] + public async Task Add_PdfExtensionWithoutMime_DetectsKindByExtension() + { + (CardsService service, FakeKanjStore store, _) = Create(); + store.SeedCard(Card("c_1")); + + CardFileDto? entry = await service.AddFileAsync( + "c_1", "tz.pdf", contentType: null, new MemoryStream("тз"u8.ToArray()), size: 2, CancellationToken.None); + + Assert.NotNull(entry); + Assert.Equal("document", entry!.Kind); // без MIME — расширение по наборам files.py KIND_BY_EXT + Assert.Equal("Документ", entry.Label); + Assert.Equal("tz.pdf", entry.Name); + } + + [Fact] + public async Task Add_TwoFiles_AppendsPreservingOrderAndBothObjects() + { + (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); + store.SeedCard(Card("c_1")); + + CardFileDto? first = await service.AddFileAsync( + "c_1", "a.txt", "text/plain", new MemoryStream("aaa"u8.ToArray()), 3, CancellationToken.None); + CardFileDto? second = await service.AddFileAsync( + "c_1", "b.png", "image/png", new MemoryStream("bbb"u8.ToArray()), 3, CancellationToken.None); + + Assert.NotNull(first); + Assert.NotNull(second); + CardDto card = Assert.Single(store.CardDtos); + Assert.Equal(new[] { first, second }, card.Files); // порядок добавления сохраняется (append в конец) + Assert.Equal(2, storage.StoredObjectKeys.Count); // оба объекта сохранены + Assert.Equal(2, card.Files.Select(file => file.ObjectKey).Distinct().Count()); + } + + [Fact] + public async Task Add_CardMissing_ReturnsNullAndDoesNotWriteObject() + { + (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); + + CardFileDto? entry = await 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); + } + + [Fact] + public async Task Add_EmptyOrWhitespaceFileName_DefaultsToPrototypeFile() + { + (CardsService service, FakeKanjStore store, _) = Create(); + store.SeedCard(Card("c_1")); + + CardFileDto? entry = await service.AddFileAsync( + "c_1", " ", contentType: null, new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None); + + // 1:1 прототип: «f.filename or "file"» — пустое имя не ошибка, а дефолт «file». + Assert.NotNull(entry); + Assert.Equal("file", entry!.Name); + Assert.Equal("other", entry.Kind); // расширения нет — other/«Файл» + Assert.Equal("Файл", entry.Label); + Assert.Matches("^projects/c_1/pf_[0-9a-f]{12}_\\d+_file$", entry.ObjectKey); + } + + [Fact] + public async Task Add_FileNameWithPathAndQuoteChars_SanitizesObjectKeyButKeepsMetaName() + { + (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); + store.SeedCard(Card("c_1")); + string rawName = "..\\файл\"отчёта v2.pdf"; + + CardFileDto? entry = await service.AddFileAsync( + "c_1", rawName, contentType: null, new MemoryStream("x"u8.ToArray()), 1, CancellationToken.None); + + Assert.NotNull(entry); + Assert.Equal(rawName, entry!.Name); // в метаданных имя остаётся как прислано + // В objectKey path-разделители и кавычки заменяются «_» — у ключа нет лишних сегментов пути. + Assert.Matches("^projects/c_1/pf_[0-9a-f]{12}_\\d+_\\.\\._файл_отчёта v2\\.pdf$", entry.ObjectKey); + Assert.NotNull(storage.ContentOf(entry.ObjectKey)); + Assert.Single(store.CardDtos); + } + + [Fact] + public async Task Add_StreamPositionNotZero_StoresWholeContent() + { + (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); + store.SeedCard(Card("c_1")); + byte[] content = Encoding.UTF8.GetBytes("полное-содержимое-файла"); + using MemoryStream stream = new(content) { Position = 5 }; // эндпоинт мог прочитать поток раньше + + CardFileDto? entry = await service.AddFileAsync( + "c_1", "doc.txt", "text/plain", stream, content.LongLength, CancellationToken.None); + + // Контракт порта: Put читает содержимое с позиции 0 — в хранилище весь файл, не хвост. + Assert.NotNull(entry); + Assert.Equal(content.LongLength, entry!.Size); + Assert.Equal(content, storage.ContentOf(entry.ObjectKey)); + } + + // ─── GetEntry (files.py get_file_entry L78–83; download-эндпоинт) ──────── + + [Fact] + public async Task GetEntry_ExistingFile_ReturnsEntryMeta() + { + (CardsService service, FakeKanjStore store, _) = Create(); + var file = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000000_tz.pdf"); + store.SeedCard(Card("c_1") with { Files = new[] { file } }); + + CardFileDto? entry = await service.GetFileEntryAsync("c_1", "pf_1", CancellationToken.None); + + Assert.NotNull(entry); + Assert.Equal(file, entry); // мета для download: id/name/size/kind/label/objectKey + } + + [Fact] + public async Task GetEntry_CardMissing_ReturnsNull() + { + (CardsService service, _, _) = Create(); + + CardFileDto? entry = await service.GetFileEntryAsync("c_missing", "pf_1", CancellationToken.None); + + Assert.Null(entry); // 404 «Карточка не найдена» у эндпоинта + } + + [Fact] + public async Task GetEntry_FileNotInMetadata_ReturnsNull() + { + (CardsService service, FakeKanjStore 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-семантика + } + + // ─── Remove (files.py remove_file L86–94) ─────────────────────────────── + + [Fact] + public async Task Remove_Existing_DeletesObjectRemovesMetaAndReturnsCard() + { + (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); + var first = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000001_tz.pdf"); + var second = new CardFileDto("pf_2", "photo.png", 200, "image", "Изображение", "projects/c_1/1710000000002_photo.png"); + store.SeedCard(Card("c_1", updatedAtMs: 1) with { Files = new[] { first, second } }); + await storage.PutAsync(first.ObjectKey, new MemoryStream("t"u8.ToArray()), "application/pdf", CancellationToken.None); + await storage.PutAsync(second.ObjectKey, new MemoryStream("p"u8.ToArray()), "image/png", CancellationToken.None); + + CardDto? card = await service.RemoveFileAsync("c_1", "pf_1", CancellationToken.None); + + Assert.NotNull(card); + Assert.Equal("pf_2", Assert.Single(card!.Files).Id); // из мета убрана только указанная запись + Assert.Equal(new[] { first.ObjectKey }, storage.DeletedKeys); // объект удалён из хранилища + Assert.Null(storage.ContentOf(first.ObjectKey)); + Assert.NotNull(storage.ContentOf(second.ObjectKey)); // объект соседней записи не тронут + CardDto stored = Assert.Single(store.CardDtos); + Assert.Equal("pf_2", Assert.Single(stored.Files).Id); + Assert.True(stored.UpdatedAtMs > 1); // удаление бампает UpdatedAt + } + + [Fact] + public async Task Remove_UnknownFileId_ReturnsCardUnchangedWithoutStorageDelete() + { + (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); + var file = new CardFileDto("pf_1", "tz.pdf", 100, "document", "Документ", "projects/c_1/1710000000001_tz.pdf"); + store.SeedCard(Card("c_1") with { Files = new[] { file } }); + + CardDto? card = await service.RemoveFileAsync("c_1", "pf_ghost", CancellationToken.None); + + // Как remove_link: записи нет — список остаётся прежним, ошибки НЕТ. + Assert.NotNull(card); + Assert.Equal("pf_1", Assert.Single(card!.Files).Id); + Assert.Empty(storage.DeletedKeys); // объект не удалялся (записи с таким id нет) + } + + [Fact] + public async Task Remove_EntryWithEmptyObjectKey_SkipsStorageDelete() + { + (CardsService service, FakeKanjStore store, FakeFileStorage storage) = Create(); + store.SeedCard(Card("c_1") + with { Files = new[] { new CardFileDto("pf_mock", "meta-only.pdf", 10, "document", "Документ", ObjectKey: string.Empty) } }); + + CardDto? card = await service.RemoveFileAsync("c_1", "pf_mock", CancellationToken.None); + + // 1:1 remove_file («entry and entry.get("objectKey")»): у записи-мока пустой ключ — удалять нечего. + Assert.NotNull(card); + Assert.Empty(card!.Files); + Assert.Empty(storage.DeletedKeys); + } + + [Fact] + public async Task Remove_CardMissing_ReturnsNullWithoutStorageDelete() + { + (CardsService service, _, FakeFileStorage storage) = Create(); + + CardDto? card = await service.RemoveFileAsync("c_missing", "pf_1", CancellationToken.None); + + Assert.Null(card); // эндпоинт отвечает 404 «Карточка не найдена» + Assert.Empty(storage.DeletedKeys); + } + + // ─── Хелперы ────────────────────────────────────────────────────────── + + private static (CardsService Service, FakeKanjStore Store, FakeFileStorage Storage) Create() + { + var store = new FakeKanjStore(); + var storage = new FakeFileStorage(); + return (new CardsService(store, new FakeSettingsStore(), new FakeMlClient(), storage), store, storage); + } + + // Карточка с полями по умолчанию (planned, CreatedAtMs=1, UpdatedAtMs=1; Files пуст). + private static CardDto Card(string id, long updatedAtMs = 1) + { + return new CardDto + { + Id = id, + Col = "planned", + CreatedAtMs = 1, + UpdatedAtMs = updatedAtMs, + }; + } +} diff --git a/src/core/tests/Deal.Tests.Unit/CardsServiceRemindersTests.cs b/src/core/tests/Deal.Tests.Unit/CardsServiceRemindersTests.cs index 5bf7c15..98fab26 100644 --- a/src/core/tests/Deal.Tests.Unit/CardsServiceRemindersTests.cs +++ b/src/core/tests/Deal.Tests.Unit/CardsServiceRemindersTests.cs @@ -1,230 +1,236 @@ -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application; - -namespace Deal.Tests.Unit; - -/// -/// Тесты напоминаний «Отложено» — (единый домен карточки, этап 9): set -/// (выключатель/404/запись), clear, snooze (+24 ч), CheckDueRemindersAsync (disabled → очистка протухших, -/// enabled → fired + due) (projects.py L236–282; Ruling 3). -/// -/// -/// Зависимости — фейки (строки Cards: Reminder + «fired»-множество) и -/// (remindersEnabled: отсутствие строки = дефолт true из SettingsDefaults; -/// выключение — Preload("false")). Семантика результатов: Error = 400-текст прототипа, Card=null без Error = -/// 404 «Карточка не найдена», bool-методы — true = ok / false = 404. Прошлые at допустимы (валидации времени -/// нет, Ruling 3); «выстреливание» проверяется через store.ListDueRemindersAsync. -/// -public sealed class CardsServiceRemindersTests -{ - // Шаг snooze — +24 часа в epoch-мс (прототип C.DAY_MS; допуск оконного сравнения теста). - private const long DayMs = 86_400_000; - - // ─── Set (set_reminder L236–243) ─────────────────────────────────────── - - [Fact] - public async Task Set_RemindersDisabled_Returns400TextAndWritesNothing() - { - (CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false); - store.SeedCard(Card("c_1", stage: "hold", title: "Отложенный бот")); - - CardResultDto result = await service.SetReminderAsync("c_1", NowMs() + DayMs, CancellationToken.None); - - Assert.Equal(CardsService.RemindersDisabledDetail, result.Error); // 400 у эндпоинта - Assert.Null(result.Card); - Assert.Null(Assert.Single(store.CardDtos).Reminder); // запись не выполнена - } - - [Fact] - public async Task Set_Enabled_SetsReminderAndReturnsCard() - { - (CardsService service, FakeKanjStore store, _) = Create(); - store.SeedCard(Card("c_1", stage: "hold", title: "Отложенный бот", updatedAtMs: 1)); - long atMs = NowMs() + DayMs; - - CardResultDto result = await service.SetReminderAsync("c_1", atMs, CancellationToken.None); - - Assert.Null(result.Error); - CardDto card = result.Card!; - Assert.Equal("c_1", card.Id); - Assert.Equal(atMs, card.Reminder!.At); // наружу reminder={at}, epoch-ms - CardDto stored = Assert.Single(store.CardDtos); - Assert.Equal(atMs, stored.Reminder!.At); - Assert.True(stored.UpdatedAtMs > 1); // set_reminder бампит updated_at - } - - [Fact] - public async Task Set_MissingCard_Returns404EvenWhenDisabled() - { - // Порядок 1:1 с роутером: карточка проверяется ДО выключателя — 404 раньше 400. - (CardsService service, _, _) = Create(remindersEnabled: false); - - CardResultDto result = await service.SetReminderAsync("c_missing", NowMs(), CancellationToken.None); - - Assert.Null(result.Error); - Assert.Null(result.Card); // эндпоинт отвечает 404 «Карточка не найдена» - } - - [Fact] - public async Task Set_StageNotHold_Allowed() - { - // Стадия карточки НЕ проверяется (Ruling 3: фронт шлёт напоминание только для hold). - (CardsService service, FakeKanjStore store, _) = Create(); - store.SeedCard(Card("c_1", stage: "work", title: "В работе")); - long atMs = NowMs() + DayMs; - - CardResultDto result = await service.SetReminderAsync("c_1", atMs, CancellationToken.None); - - Assert.Null(result.Error); - Assert.Equal("work", result.Card!.Col); - Assert.Equal(atMs, result.Card!.Reminder!.At); - } - - // ─── Clear (clear_reminder L246–247) ─────────────────────────────────── - - [Fact] - public async Task Clear_WithReminder_ClearsItAndReturnsTrue() - { - (CardsService service, FakeKanjStore store, _) = Create(); - store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) }); - - bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None); - - Assert.True(cleared); - Assert.Null(Assert.Single(store.CardDtos).Reminder); // reminder_at=NULL, fired сброшен - } - - [Fact] - public async Task Clear_MissingCard_ReturnsFalse() - { - (CardsService service, _, _) = Create(); - - bool cleared = await service.ClearReminderAsync("c_missing", CancellationToken.None); - - Assert.False(cleared); // эндпоинт отвечает 404 «Карточка не найдена» - } - - [Fact] - public async Task Clear_RemindersDisabled_StillClears() - { - // clear выключатель НЕ проверяет (Ruling 3) — снять можно и при выключенных. - (CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false); - store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() + DayMs) }); - - bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None); - - Assert.True(cleared); - Assert.Null(Assert.Single(store.CardDtos).Reminder); - } - - // ─── Snooze (snooze L257–261) ────────────────────────────────────────── - - [Fact] - public async Task Snooze_MovesReminderToNowPlus24h() - { - (CardsService service, FakeKanjStore store, _) = Create(); - store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) }); - long beforeMs = NowMs(); - - bool snoozed = await service.SnoozeReminderAsync("c_1", CancellationToken.None); - - Assert.True(snoozed); - long afterMs = NowMs(); - CardReminderDto reminder = Assert.Single(store.CardDtos).Reminder!; - Assert.InRange(reminder.At, beforeMs + DayMs, afterMs + DayMs); // now + 24 ч - } - - [Fact] - public async Task Snooze_MissingCard_ReturnsFalse() - { - (CardsService service, _, _) = Create(); - - bool snoozed = await service.SnoozeReminderAsync("c_missing", CancellationToken.None); - - Assert.False(snoozed); // эндпоинт отвечает 404 «Карточка не найдена» - } - - [Fact] - public async Task Snooze_RemindersDisabled_StillSnoozes() - { - // snooze выключатель НЕ проверяет (Ruling 3): баннер напоминания зовёт его и при выключенной настройке. - (CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false); - store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) }); - - bool snoozed = await service.SnoozeReminderAsync("c_1", CancellationToken.None); - - Assert.True(snoozed); - Assert.NotNull(Assert.Single(store.CardDtos).Reminder); - } - - // ─── CheckDueRemindersAsync (check_reminders L264–282) ────────────────── - - [Fact] - public async Task CheckDue_Disabled_ClearsExpiredAndReturnsEmpty() - { - (CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false); - long pastMs = NowMs() - 1; - store.SeedCard(Card("c_hold_past", stage: "hold") with { Reminder = new CardReminderDto(pastMs) }); - store.SeedCard(Card("c_work_past", stage: "work") with { Reminder = new CardReminderDto(pastMs) }); - store.SeedCard(Card("c_future", stage: "hold") with { Reminder = new CardReminderDto(NowMs() + DayMs) }); - - IReadOnlyList due = await service.CheckDueRemindersAsync(CancellationToken.None); - - Assert.Empty(due); // выключено → событий нет - // Протухшие очищены БЕЗ учёта stage/fired, будущее не тронуто. - Assert.Null(store.CardDtos.Single(card => card.Id == "c_hold_past").Reminder); - Assert.Null(store.CardDtos.Single(card => card.Id == "c_work_past").Reminder); - Assert.NotNull(store.CardDtos.Single(card => card.Id == "c_future").Reminder); - } - - [Fact] - public async Task CheckDue_Enabled_ReturnsDueAndMarksFired() - { - (CardsService service, FakeKanjStore store, _) = Create(); - store.SeedCard(Card("c_d1", stage: "hold", title: "Ранний") with { Reminder = new CardReminderDto(NowMs() - 2 * DayMs) }); - store.SeedCard(Card("c_d2", stage: "hold", title: "Поздний") with { Reminder = new CardReminderDto(NowMs() - 1) }); - store.SeedCard(Card("c_future", stage: "hold", title: "Будущий") with { Reminder = new CardReminderDto(NowMs() + DayMs) }); - store.SeedCard(Card("c_work", stage: "work", title: "В работе") with { Reminder = new CardReminderDto(NowMs() - 1) }); - - IReadOnlyList due = await service.CheckDueRemindersAsync(CancellationToken.None); - - // Только hold-карточки с наступившим и не сработавшим напоминанием, ORDER BY reminder_at. - Assert.Equal( - new[] { ("c_d1", "Ранний"), ("c_d2", "Поздний") }, - due.Select(item => (item.Id, item.Title))); - Assert.All(due, item => Assert.Equal("hold", item.ContainerId)); - // «Выстрелившие» помечены fired: повторная выборка due пуста (признак держит строка БД). - Assert.Empty(await store.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None)); - } - - // ─── Хелперы ────────────────────────────────────────────────────────── - - private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings) Create(bool remindersEnabled = true) - { - var store = new FakeKanjStore(); - var settings = new FakeSettingsStore(); - if (!remindersEnabled) - { - settings.Preload(SettingsKeys.RemindersEnabled, "false"); - } - - return (new CardsService(store, settings, new FakeMlClient(), new FakeFileStorage()), store, settings); - } - - // Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем. - private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - - // Карточка с полями по умолчанию (planned, CreatedAtMs=1, UpdatedAtMs=1). - private static CardDto Card(string id, string stage = "planned", string title = "", long updatedAtMs = 1) - { - return new CardDto - { - Id = id, - Col = stage, - Title = title, - CreatedAtMs = 1, - UpdatedAtMs = updatedAtMs, - }; - } -} +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; + +namespace Deal.Tests.Unit; + +/// +/// Тесты напоминаний «Отложено» — (единый домен карточки, этап 9): set +/// (выключатель/404/запись), clear, snooze (+24 ч), CheckDueRemindersAsync (disabled → очистка протухших, +/// enabled → fired + due) (projects.py L236–282; Ruling 3). +/// +/// +/// Зависимости — фейки (строки Cards: Reminder + «fired»-множество) и +/// (remindersEnabled: отсутствие строки = дефолт true из SettingsDefaults; +/// выключение — Preload("false")). Семантика результатов: Error = 400-текст прототипа, Card=null без Error = +/// 404 «Карточка не найдена», bool-методы — true = ok / false = 404. Прошлые at допустимы (валидации времени +/// нет, Ruling 3); «выстреливание» проверяется через store.ListDueRemindersAsync. +/// +public sealed class CardsServiceRemindersTests +{ + // Шаг snooze — +24 часа в epoch-мс (прототип C.DAY_MS; допуск оконного сравнения теста). + private const long DayMs = 86_400_000; + + // ─── Set (set_reminder L236–243) ─────────────────────────────────────── + + [Fact] + public async Task Set_RemindersDisabled_Returns400TextAndWritesNothing() + { + (CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false); + store.SeedCard(Card("c_1", stage: "hold", title: "Отложенный бот")); + + CardResultDto result = await service.SetReminderAsync("c_1", NowMs() + DayMs, CancellationToken.None); + + Assert.Equal(CardsService.RemindersDisabledDetail, result.Error); // 400 у эндпоинта + Assert.Null(result.Card); + Assert.Null(Assert.Single(store.CardDtos).Reminder); // запись не выполнена + } + + [Fact] + public async Task Set_Enabled_SetsReminderAndReturnsCard() + { + (CardsService service, FakeKanjStore store, _) = Create(); + store.SeedCard(Card("c_1", stage: "hold", title: "Отложенный бот", updatedAtMs: 1)); + long atMs = NowMs() + DayMs; + + CardResultDto result = await service.SetReminderAsync("c_1", atMs, CancellationToken.None); + + Assert.Null(result.Error); + CardDto card = result.Card!; + Assert.Equal("c_1", card.Id); + Assert.Equal(atMs, card.Reminder!.At); // наружу reminder={at}, epoch-ms + CardDto stored = Assert.Single(store.CardDtos); + Assert.Equal(atMs, stored.Reminder!.At); + Assert.True(stored.UpdatedAtMs > 1); // set_reminder бампит updated_at + } + + [Fact] + public async Task Set_MissingCard_Returns404EvenWhenDisabled() + { + // Порядок 1:1 с роутером: карточка проверяется ДО выключателя — 404 раньше 400. + (CardsService service, _, _) = Create(remindersEnabled: false); + + CardResultDto result = await service.SetReminderAsync("c_missing", NowMs(), CancellationToken.None); + + Assert.Null(result.Error); + Assert.Null(result.Card); // эндпоинт отвечает 404 «Карточка не найдена» + } + + [Fact] + public async Task Set_StageNotHold_Allowed() + { + // Стадия карточки НЕ проверяется (Ruling 3: фронт шлёт напоминание только для hold). + (CardsService service, FakeKanjStore store, _) = Create(); + store.SeedCard(Card("c_1", stage: "work", title: "В работе")); + long atMs = NowMs() + DayMs; + + CardResultDto result = await service.SetReminderAsync("c_1", atMs, CancellationToken.None); + + Assert.Null(result.Error); + Assert.Equal("work", result.Card!.Col); + Assert.Equal(atMs, result.Card!.Reminder!.At); + } + + // ─── Clear (clear_reminder L246–247) ─────────────────────────────────── + + [Fact] + public async Task Clear_WithReminder_ClearsItAndReturnsTrue() + { + (CardsService service, FakeKanjStore store, _) = Create(); + store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) }); + + bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None); + + Assert.True(cleared); + Assert.Null(Assert.Single(store.CardDtos).Reminder); // reminder_at=NULL, fired сброшен + } + + [Fact] + public async Task Clear_MissingCard_ReturnsFalse() + { + (CardsService service, _, _) = Create(); + + bool cleared = await service.ClearReminderAsync("c_missing", CancellationToken.None); + + Assert.False(cleared); // эндпоинт отвечает 404 «Карточка не найдена» + } + + [Fact] + public async Task Clear_RemindersDisabled_StillClears() + { + // clear выключатель НЕ проверяет (Ruling 3) — снять можно и при выключенных. + (CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false); + store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() + DayMs) }); + + bool cleared = await service.ClearReminderAsync("c_1", CancellationToken.None); + + Assert.True(cleared); + Assert.Null(Assert.Single(store.CardDtos).Reminder); + } + + // ─── Snooze (snooze L257–261) ────────────────────────────────────────── + + [Fact] + public async Task Snooze_MovesReminderToNowPlus24h() + { + (CardsService service, FakeKanjStore store, _) = Create(); + store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) }); + long beforeMs = NowMs(); + + bool snoozed = await service.SnoozeReminderAsync("c_1", CancellationToken.None); + + Assert.True(snoozed); + long afterMs = NowMs(); + CardReminderDto reminder = Assert.Single(store.CardDtos).Reminder!; + Assert.InRange(reminder.At, beforeMs + DayMs, afterMs + DayMs); // now + 24 ч + } + + [Fact] + public async Task Snooze_MissingCard_ReturnsFalse() + { + (CardsService service, _, _) = Create(); + + bool snoozed = await service.SnoozeReminderAsync("c_missing", CancellationToken.None); + + Assert.False(snoozed); // эндпоинт отвечает 404 «Карточка не найдена» + } + + [Fact] + public async Task Snooze_RemindersDisabled_StillSnoozes() + { + // snooze выключатель НЕ проверяет (Ruling 3): баннер напоминания зовёт его и при выключенной настройке. + (CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false); + store.SeedCard(Card("c_1", stage: "hold") with { Reminder = new CardReminderDto(NowMs() - 1) }); + + bool snoozed = await service.SnoozeReminderAsync("c_1", CancellationToken.None); + + Assert.True(snoozed); + Assert.NotNull(Assert.Single(store.CardDtos).Reminder); + } + + // ─── CheckDueRemindersAsync (check_reminders L264–282) ────────────────── + + [Fact] + public async Task CheckDue_Disabled_ClearsExpiredAndReturnsEmpty() + { + (CardsService service, FakeKanjStore store, _) = Create(remindersEnabled: false); + long pastMs = NowMs() - 1; + store.SeedCard(Card("c_hold_past", stage: "hold") with { Reminder = new CardReminderDto(pastMs) }); + store.SeedCard(Card("c_work_past", stage: "work") with { Reminder = new CardReminderDto(pastMs) }); + store.SeedCard(Card("c_future", stage: "hold") with { Reminder = new CardReminderDto(NowMs() + DayMs) }); + + IReadOnlyList due = await service.CheckDueRemindersAsync(CancellationToken.None); + + Assert.Empty(due); // выключено → событий нет + // Протухшие очищены БЕЗ учёта stage/fired, будущее не тронуто. + Assert.Null(store.CardDtos.Single(card => card.Id == "c_hold_past").Reminder); + Assert.Null(store.CardDtos.Single(card => card.Id == "c_work_past").Reminder); + Assert.NotNull(store.CardDtos.Single(card => card.Id == "c_future").Reminder); + } + + [Fact] + public async Task CheckDue_Enabled_ReturnsDueAndMarksFired() + { + (CardsService service, FakeKanjStore store, _) = Create(); + store.SeedCard(Card("c_d1", stage: "hold", title: "Ранний") with { Reminder = new CardReminderDto(NowMs() - 2 * DayMs) }); + store.SeedCard(Card("c_d2", stage: "hold", title: "Поздний") with { Reminder = new CardReminderDto(NowMs() - 1) }); + store.SeedCard(Card("c_future", stage: "hold", title: "Будущий") with { Reminder = new CardReminderDto(NowMs() + DayMs) }); + store.SeedCard(Card("c_work", stage: "work", title: "В работе") with { Reminder = new CardReminderDto(NowMs() - 1) }); + + IReadOnlyList due = await service.CheckDueRemindersAsync(CancellationToken.None); + + // Только hold-карточки с наступившим и не сработавшим напоминанием, ORDER BY reminder_at. + Assert.Equal( + new[] { ("c_d1", "Ранний"), ("c_d2", "Поздний") }, + due.Select(item => (item.Id, item.Title))); + Assert.All(due, item => Assert.Equal("hold", item.ContainerId)); + // «Выстрелившие» помечены fired: повторная выборка due пуста (признак держит строка БД). + Assert.Empty(await store.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None)); + } + + // ─── Хелперы ────────────────────────────────────────────────────────── + + private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings) Create(bool remindersEnabled = true) + { + var store = new FakeKanjStore(); + var settings = new FakeSettingsStore(); + if (!remindersEnabled) + { + settings.Preload(SettingsKeys.RemindersEnabled, "false"); + } + + return (new CardsService(store, settings, new FakeMlClient(), new FakeFileStorage()), store, settings); + } + + // Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем. + private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + // Карточка с полями по умолчанию (planned, CreatedAtMs=1, UpdatedAtMs=1). + private static CardDto Card(string id, string stage = "planned", string title = "", long updatedAtMs = 1) + { + return new CardDto + { + Id = id, + Col = stage, + Title = title, + CreatedAtMs = 1, + UpdatedAtMs = updatedAtMs, + }; + } +} diff --git a/src/core/tests/Deal.Tests.Unit/CardsServiceSelectedTests.cs b/src/core/tests/Deal.Tests.Unit/CardsServiceSelectedTests.cs index 429f7b7..620e47b 100644 --- a/src/core/tests/Deal.Tests.Unit/CardsServiceSelectedTests.cs +++ b/src/core/tests/Deal.Tests.Unit/CardsServiceSelectedTests.cs @@ -1,602 +1,605 @@ -using System.Text.Json; -using Deal.Modules.Cards.Application.Abstractions; -using Deal.Modules.Cards.Application.Dtos; -using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.Models; - -namespace Deal.Tests.Unit; - -/// -/// Тесты операций карточек пространства «Выбранные» — (единый домен -/// карточки, этап 9): чтение, ручное создание, «взять в работу», патч, комментарии/ссылки, -/// move по стадии + история/сброс напоминания, очистка «Отклонено» (projects.py L103–231). -/// -/// -/// Зависимости — фейк (единые строки Cards; поведение 1:1 с EF-адаптером -/// KanbanStore), FakeSettingsStore, FakeMlClient и FakeFileStorage. -/// Семантика результатов: null-карточка = 404 (текст у эндпоинта), Error = 400-строка прототипа -/// (сверяется с константой ). -/// -public sealed class CardsServiceSelectedTests -{ - // ─── Чтение (list_cards/get_card L58–68) ──────────────────────────────── - - [Fact] - public async Task List_NoStage_ReturnsCardsOrderedByUpdatedAtDesc() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", updatedAtMs: 100)); - store.SeedCard(Card("c_2", updatedAtMs: 300)); - store.SeedCard(Card("c_3", stage: "work", updatedAtMs: 200)); - - IReadOnlyList cards = await service.ListSelectedCardsAsync(containerId: null, CancellationToken.None); - - Assert.Equal(new[] { "c_2", "c_3", "c_1" }, cards.Select(card => card.Id)); - } - - [Fact] - public async Task List_ByStage_ReturnsOnlyStageCards() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1")); - store.SeedCard(Card("c_2", stage: "work")); - - IReadOnlyList cards = await service.ListSelectedCardsAsync("work", CancellationToken.None); - - CardDto card = Assert.Single(cards); - Assert.Equal("c_2", card.Id); - } - - [Fact] - public async Task Get_Missing_ReturnsNull() - { - (CardsService service, _, _, _) = Create(); - - CardDto? card = await service.GetCardAsync("c_missing", CancellationToken.None); - - Assert.Null(card); - } - - // ─── Ручное создание (create_local_card L103–124, Ruling 6) ───────────── - - [Fact] - public async Task CreateLocal_WithStage_TrimsTitleAndWritesCreatedLocalHistory() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - - CardDto card = await service.CreateLocalCardAsync( - new CardLocalCreateDto(Title: " Задача на бота ", ContainerId: "work"), - CancellationToken.None); - - Assert.True(card.Local); - Assert.Equal("work", card.Col); - Assert.Equal("Задача на бота", card.Title); // title — Trim() (Ruling 6) - CardHistoryDto entry = Assert.Single(card.History); - Assert.StartsWith("h_", entry.Id); - Assert.Equal("createdLocal", entry.Type); - Assert.Null(entry.Stage); - Assert.True(entry.At > 0); - Assert.Single(store.CardDtos); - } - - [Fact] - public async Task CreateLocal_UnknownOrEmptyStage_FallsBackToPlanned() - { - (CardsService service, _, _, _) = Create(); - - CardDto unknown = await service.CreateLocalCardAsync( - new CardLocalCreateDto(Title: "Нет такой стадии", ContainerId: "stuck"), - CancellationToken.None); - CardDto empty = await service.CreateLocalCardAsync( - new CardLocalCreateDto(Title: "Стадии нет"), - CancellationToken.None); - - Assert.Equal("planned", unknown.Col); - Assert.Equal("planned", empty.Col); - Assert.True(unknown.Local); - } - - [Fact] - public async Task CreateLocal_EmptyTitle_Allowed() - { - (CardsService service, _, _, _) = Create(); - - CardDto card = await service.CreateLocalCardAsync(new CardLocalCreateDto(), CancellationToken.None); - - Assert.Equal(string.Empty, card.Title); // фронт шлёт {title:''} — 1:1 прототип (Ruling 6) - Assert.Equal("planned", card.Col); - } - - // ─── «Взять в работу» (take_lead_to_projects L127–156, Ruling 5) ──────── - - [Fact] - public async Task TakeCard_CardMissing_ReturnsNullAndCreatesNothing() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - - CardDto? card = await service.TakeCardAsync("c_missing", CancellationToken.None); - - Assert.Null(card); - Assert.Empty(store.CardDtos); - } - - [Fact] - public async Task TakeCard_MovesCardToPlannedKeepingFields() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card( - "c_1", - stage: "inbox", - title: "Middle Python на бота", - summary: "Компания: X\nФормат: проект", - contact: "@user", - 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 при существующей карточке"); - - // Та же карточка переехала в стадию planned, поля сохранены (Ruling 4/5). - Assert.Equal("c_1", card.Id); - Assert.Equal("planned", card.Col); - Assert.False(card.Local); - Assert.Equal("Middle Python на бота", card.Title); - Assert.Equal("Компания: X\nФормат: проект", card.Summary); - Assert.Equal("@user", card.Contact); - Assert.Equal(new[] { "Python", "aiogram" }, card.Stack); - Assert.Equal(new CardBudgetDto(1600, 2200, "USD"), card.Budget); - // Комментарий-«взял в работу». - CardCommentDto comment = Assert.Single(card.Comments); - Assert.StartsWith("cm_", comment.Id); - Assert.Equal("Вы", comment.By); - Assert.Equal("Взял в работу.", comment.Text); - // История — запись переноса в planned. - CardHistoryDto history = Assert.Single(card.History); - Assert.StartsWith("h_", history.Id); - Assert.Null(history.Type); - Assert.Equal("planned", history.Stage); - Assert.Single(store.CardDtos); - } - - [Fact] - public async Task TakeCard_AlreadyInStage_ReturnsCardUnchanged() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", stage: "work", title: "Уже взята")); - - CardDto? card = await service.TakeCardAsync("c_1", CancellationToken.None); - - // Идемпотентность: карточка уже в стадии — возврат без изменений и без второго комментария. - Assert.NotNull(card); - Assert.Equal("c_1", card!.Id); - Assert.Equal("work", card.Col); - Assert.Empty(card.Comments); - Assert.Single(store.CardDtos); - } - - // ─── Правка полей (patch_card L159–187; presence-aware тело PATCH) ────── - - [Fact] - public async Task Patch_PresentKeys_UpdateFieldsAndBumpUpdatedAt() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card( - "c_1", - title: "Старый заголовок", - summary: "Старое описание", - contact: "@old", - updatedAtMs: 1)); - - CardDto card = await service.PatchCardAsync( - "c_1", - PatchBody( - ("title", "Новый заголовок"), - ("summary", string.Empty), // присутствующий ключ со "" — очистка текста - ("tzText", "ТЗ"), - ("stack", new[] { "C#", ".NET" }), // стек — полная замена массива - ("budget", new { from = 500, cur = "EUR" })), - CancellationToken.None) - ?? throw new InvalidOperationException("patch вернул null при существующей карточке"); - - Assert.Equal("Новый заголовок", card.Title); - Assert.Equal(string.Empty, card.Summary); // summary очищена пустой строкой - Assert.Equal("@old", card.Contact); // ключ contact отсутствует — поле не меняется - Assert.Equal("ТЗ", card.TzText); - Assert.Equal(new[] { "C#", ".NET" }, card.Stack); - Assert.Equal(new CardBudgetDto(500, null, "EUR"), card.Budget); // бюджет из from/to/cur - Assert.True(card.UpdatedAtMs > 1); // bump UpdatedAt - } - - [Fact] - public async Task Patch_BudgetNull_ClearsBudget() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", title: "С бюджетом", updatedAtMs: 1) - with { Budget = new CardBudgetDto(1000, 2000, "USD") }); - - CardDto? card = await service.PatchCardAsync("c_1", PatchBody(("budget", null)), CancellationToken.None); - - // Фронт шлёт явный budget:null для очистки — budget снят (Ruling 11). - Assert.NotNull(card); - Assert.Null(card!.Budget); - CardDto stored = Assert.Single(store.CardDtos); - Assert.Null(stored.Budget); - } - - [Fact] - public async Task Patch_StackNull_ClearsStack() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", title: "С стеком", updatedAtMs: 1) - with { Stack = new[] { "Python" } }); - - CardDto? card = await service.PatchCardAsync("c_1", PatchBody(("stack", null)), CancellationToken.None); - - // Прототип: _json(patch["stack"] or []) — явный null очищает стек (patch_card L172–173). - Assert.NotNull(card); - Assert.Empty(card!.Stack); - } - - [Fact] - public async Task Patch_UnknownKeys_AreIgnored() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", title: "Старый")); - - CardDto? card = await service.PatchCardAsync( - "c_1", - PatchBody(("stage", "finished"), ("title", "Новый")), - CancellationToken.None); - - Assert.NotNull(card); - Assert.Equal("Новый", card!.Title); // известный ключ применён - Assert.Equal("planned", card.Col); // неизвестный ключ (stage) отброшен, как pydantic PatchBody - } - - [Fact] - public async Task Patch_NullTextKey_IsIgnored() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", title: "Старый", summary: "Описание", updatedAtMs: 1)); - - CardDto? card = await service.PatchCardAsync( - "c_1", - PatchBody(("title", null), ("summary", "Новое описание")), - CancellationToken.None); - - // Текстовые поля прототип НЕ очищает null-ом (str(None) — баг); JSON-null трактуется как отсутствие - // ключа — title не тронут, summary обновлён. - Assert.NotNull(card); - Assert.Equal("Старый", card!.Title); - Assert.Equal("Новое описание", card.Summary); - } - - [Fact] - public async Task Patch_CardMissing_ReturnsNull() - { - (CardsService service, _, _, _) = Create(); - - CardDto? card = await service.PatchCardAsync( - "c_missing", - PatchBody(("title", "Т")), - CancellationToken.None); - - Assert.Null(card); - } - - // ─── Move + история + сброс напоминания (move_stage L202–216) ────────── - - [Fact] - public async Task Move_TwoMoves_AppendHistoryResetReminderAndBumpUpdatedAt() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - long seededAt = 5_000; - CardDto before = Card("c_1", stage: "hold", title: "Отложенная", updatedAtMs: seededAt) - with - { - Reminder = new CardReminderDto(seededAt + 1_000), - History = new[] { new CardHistoryDto("h_seed", seededAt, "created", null) }, - }; - store.SeedCard(before); - - CardResultDto first = await service.MoveStageCardAsync("c_1", "work", CancellationToken.None); - CardResultDto second = await service.MoveStageCardAsync("c_1", "review", CancellationToken.None); - CardDto? card = second.Card; - - Assert.Null(first.Error); - Assert.Null(second.Error); - Assert.NotNull(card); - Assert.Equal("review", card!.Col); - Assert.Null(card.Reminder); // любой move сбрасывает напоминание (Ruling 3) - - // История КОПИТСЯ (append, не замена): создание + записи обоих move в порядке переносов (Ruling 7). - Assert.Equal(3, card.History.Count); - Assert.Equal( - new[] { "created", "work", "review" }, - card.History.Select(entry => entry.Stage ?? entry.Type ?? string.Empty)); - Assert.All(card.History, entry => Assert.StartsWith("h_", entry.Id)); - Assert.Equal(card.UpdatedAtMs, card.History[^1].At); // updated_at = время последнего переноса - Assert.True(card.UpdatedAtMs >= seededAt); - Assert.Equal(before.Id, card.Id); - - CardDto stored = Assert.Single(store.CardDtos); - Assert.Equal("review", stored.Col); - Assert.Null(stored.Reminder); - Assert.Equal(3, stored.History.Count); // в хранилище та же накопленная история - } - - [Fact] - public async Task Move_UnknownStage_Returns400ErrorAndLeavesCardUntouched() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", stage: "planned")); - - CardResultDto result = await service.MoveStageCardAsync("c_1", "stuck", CancellationToken.None); - - Assert.Equal(CardsService.UnknownStageDetail, result.Error); - Assert.Null(result.Card); - CardDto stored = Assert.Single(store.CardDtos); - Assert.Equal("planned", stored.Col); - Assert.Empty(stored.History); - } - - [Fact] - public async Task Move_CardMissing_ReturnsNullCard() - { - (CardsService service, _, _, _) = Create(); - - CardResultDto result = await service.MoveStageCardAsync("c_missing", "work", CancellationToken.None); - - Assert.Null(result.Error); - Assert.Null(result.Card); // 404 «Карточка не найдена» — текст у эндпоинта - } - - // ─── Очистка «Отклонено» (clear_stage L223–231, Ruling 9) ─────────────── - - [Fact] - public async Task ClearRejected_RemovesOnlyRejectedAndReturnsCount() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", stage: "rejected")); - store.SeedCard(Card("c_2", stage: "rejected")); - store.SeedCard(Card("c_3", stage: "finished")); - store.SeedCard(Card("c_4", stage: "planned")); - - int cleared = await service.ClearRejectedAsync(CancellationToken.None); - - Assert.Equal(2, cleared); - Assert.Equal(new[] { "c_3", "c_4" }, store.CardDtos.Select(card => card.Id).OrderBy(id => id)); - } - - [Fact] - public async Task ClearRejected_EmptyStage_ReturnsZero() - { - (CardsService service, _, _, _) = Create(); - - int cleared = await service.ClearRejectedAsync(CancellationToken.None); - - Assert.Equal(0, cleared); - } - - // ─── Комментарии (add_comment L194–199, Ruling 11) ───────────────────── - - [Fact] - public async Task AddComment_Valid_AppendsTrimmedCommentWithWireForm() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1)); - - AddCommentResultDto result = await service.AddCommentAsync("c_1", " Перезвонить завтра ", CancellationToken.None); - - Assert.Null(result.Error); - CardCommentDto comment = Assert.Single(result.Comments!); - Assert.StartsWith("cm_", comment.Id); // id комментария — общий префикс карточки - Assert.Equal("Вы", comment.By); - Assert.Equal("Перезвонить завтра", comment.Text); // текст — после Trim - Assert.Equal("только что", comment.Time); - CardDto stored = Assert.Single(store.CardDtos); - Assert.Equal(comment, Assert.Single(stored.Comments)); // комментарий сохранён на карточке - } - - [Fact] - public async Task AddComment_TwoComments_AppendsPreservingOrder() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", title: "Карточка") - with { Comments = new[] { new CardCommentDto("cm_1", "Вы", "Первый", "5 мин") } }); - - AddCommentResultDto result = await service.AddCommentAsync("c_1", "Второй", CancellationToken.None); - - Assert.Null(result.Error); - Assert.NotNull(result.Comments); - Assert.Equal(new[] { "Первый", "Второй" }, result.Comments!.Select(comment => comment.Text)); - Assert.Equal(2, Assert.Single(store.CardDtos).Comments.Count); // в хранилище тот же накопленный список - } - - [Fact] - public async Task AddComment_EmptyOrWhitespaceText_Returns400DetailAndWritesNothing() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", title: "Карточка")); - - AddCommentResultDto empty = await service.AddCommentAsync("c_1", string.Empty, CancellationToken.None); - AddCommentResultDto whitespace = await service.AddCommentAsync("c_1", " ", CancellationToken.None); - - Assert.Equal(CardsService.EmptyCommentDetail, empty.Error); // «Пустой комментарий» - Assert.Equal(CardsService.EmptyCommentDetail, whitespace.Error); - Assert.Null(empty.Comments); - Assert.Empty(Assert.Single(store.CardDtos).Comments); // комментарий не записан - } - - [Fact] - public async Task AddComment_CardMissing_ReturnsNullComments() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - - AddCommentResultDto result = await service.AddCommentAsync("c_missing", "Текст", CancellationToken.None); - - Assert.Null(result.Error); - Assert.Null(result.Comments); // эндпоинт отвечает 404 «Карточка не найдена» - Assert.Empty(store.CardDtos); - } - - // ─── Ссылки (add_link/remove_link L133–150) ──────────────────────────── - - [Fact] - public async Task AddLink_NoScheme_PrefixesHttpsAndDefaultsNameToUrl() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1) - with { Links = new[] { new CardLinkDto("pl_1", "Сайт", "https://example.com") } }); - - CardResultDto result = await service.AddLinkAsync("c_1", string.Empty, " example.com ", CancellationToken.None); - - Assert.Null(result.Error); - Assert.NotNull(result.Card); - Assert.Equal(2, result.Card!.Links.Count); // ссылка дописана в конец существующих - CardLinkDto link = result.Card.Links[^1]; - Assert.StartsWith("pl_", link.Id); - Assert.Equal("https://example.com", link.Url); // без схемы → префикс https:// - Assert.Equal("https://example.com", link.Name); // пустое имя → name = url - Assert.Equal("Сайт", result.Card.Links[0].Name); // прежняя ссылка на месте, порядок сохранён - Assert.True(Assert.Single(store.CardDtos).UpdatedAtMs > 1); // append бампает UpdatedAt - } - - [Fact] - public async Task AddLink_HasHttpScheme_KeepsSchemeAndUsesTrimmedName() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", title: "Карточка")); - - CardResultDto result = await service.AddLinkAsync("c_1", " Сайт ", "http://site.ru/abc", CancellationToken.None); - - Assert.Null(result.Error); - CardLinkDto link = Assert.Single(result.Card!.Links); - Assert.Equal("http://site.ru/abc", link.Url); // схема http сохраняется - Assert.Equal("Сайт", link.Name); // name — после Trim - } - - [Fact] - public async Task AddLink_EmptyUrl_Returns400DetailAndWritesNothing() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", title: "Карточка")); - - CardResultDto result = await service.AddLinkAsync("c_1", "Имя", " ", CancellationToken.None); - - Assert.Equal(CardsService.EmptyLinkDetail, result.Error); // «Пустая ссылка» - Assert.Null(result.Card); - Assert.Empty(Assert.Single(store.CardDtos).Links); // ссылка не записана - } - - [Fact] - public async Task AddLink_CardMissing_ReturnsNullCardBeforeUrlValidation() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - - CardResultDto result = await service.AddLinkAsync("c_missing", string.Empty, "example.com", CancellationToken.None); - - Assert.Null(result.Error); - Assert.Null(result.Card); // 404-семантика: карточки нет раньше валидации url - Assert.Empty(store.CardDtos); - } - - [Fact] - public async Task RemoveLink_ById_RemovesOnlyTargetAndReturnsCard() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1) - with - { - Links = new[] - { - new CardLinkDto("pl_1", "Старая", "https://a.b"), - new CardLinkDto("pl_2", "Новая", "https://c.d"), - }, - }); - - CardResultDto result = await service.RemoveLinkAsync("c_1", "pl_1", CancellationToken.None); - - Assert.Null(result.Error); - CardLinkDto link = Assert.Single(result.Card!.Links); - Assert.Equal("pl_2", link.Id); // удалена только указанная ссылка - CardDto stored = Assert.Single(store.CardDtos); - Assert.Equal("pl_2", Assert.Single(stored.Links).Id); - Assert.True(stored.UpdatedAtMs > 1); // удаление бампает UpdatedAt - } - - [Fact] - public async Task RemoveLink_UnknownLinkId_LeavesLinksUnchangedWithoutError() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - store.SeedCard(Card("c_1", title: "Карточка") - with { Links = new[] { new CardLinkDto("pl_1", "Сайт", "https://a.b") } }); - - CardResultDto result = await service.RemoveLinkAsync("c_1", "pl_ghost", CancellationToken.None); - - Assert.Null(result.Error); - Assert.Equal("pl_1", Assert.Single(result.Card!.Links).Id); // нет ошибки — ссылка просто не найдена - } - - [Fact] - public async Task RemoveLink_CardMissing_ReturnsNullCard() - { - (CardsService service, FakeKanjStore store, _, _) = Create(); - - CardResultDto result = await service.RemoveLinkAsync("c_missing", "pl_1", CancellationToken.None); - - Assert.Null(result.Error); - Assert.Null(result.Card); // эндпоинт отвечает 404 «Карточка не найдена» - Assert.Empty(store.CardDtos); - } - - // ─── Хелперы ────────────────────────────────────────────────────────── - - private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings, FakeMlClient Ml) Create() - { - var store = new FakeKanjStore(); - var settings = new FakeSettingsStore(); - var ml = new FakeMlClient(); - return (new CardsService(store, settings, ml, new FakeFileStorage()), store, settings, ml); - } - - // Тело PATCH из пар «ключ → значение» (presence = наличие пары; null — явный JSON-null). - private static Dictionary PatchBody(params (string Key, object? Value)[] fields) - { - var body = new Dictionary(StringComparer.Ordinal); - foreach ((string key, object? value) in fields) - { - body[key] = JsonSerializer.SerializeToElement(value); - } - - return body; - } - - // Карточка с полями по умолчанию (planned, local=false, CreatedAtMs=1, UpdatedAtMs=1). - private static CardDto Card( - string id, - string stage = "planned", - string title = "", - string summary = "", - string contact = "", - bool local = false, - long updatedAtMs = 1, - IReadOnlyList? stack = null, - CardBudgetDto? budget = null) - { - return new CardDto - { - Id = id, - Col = stage, - Title = title, - Summary = summary, - Contact = contact, - Local = local, - Stack = stack ?? Array.Empty(), - Budget = budget, - CreatedAtMs = 1, - UpdatedAtMs = updatedAtMs, - }; - } -} +using System.Text.Json; +using Deal.Modules.Cards.Application.Abstractions; +using Deal.Modules.Cards.Application.Dtos; +using Deal.Modules.Cards.Application.Models; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; + +namespace Deal.Tests.Unit; + +/// +/// Тесты операций карточек пространства «Выбранные» — (единый домен +/// карточки, этап 9): чтение, ручное создание, «взять в работу», патч, комментарии/ссылки, +/// move по стадии + история/сброс напоминания, очистка «Отклонено» (projects.py L103–231). +/// +/// +/// Зависимости — фейк (единые строки Cards; поведение 1:1 с EF-адаптером +/// KanbanStore), FakeSettingsStore, FakeMlClient и FakeFileStorage. +/// Семантика результатов: null-карточка = 404 (текст у эндпоинта), Error = 400-строка прототипа +/// (сверяется с константой ). +/// +public sealed class CardsServiceSelectedTests +{ + // ─── Чтение (list_cards/get_card L58–68) ──────────────────────────────── + + [Fact] + public async Task List_NoStage_ReturnsCardsOrderedByUpdatedAtDesc() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", updatedAtMs: 100)); + store.SeedCard(Card("c_2", updatedAtMs: 300)); + store.SeedCard(Card("c_3", stage: "work", updatedAtMs: 200)); + + IReadOnlyList cards = await service.ListSelectedCardsAsync(containerId: null, CancellationToken.None); + + Assert.Equal(new[] { "c_2", "c_3", "c_1" }, cards.Select(card => card.Id)); + } + + [Fact] + public async Task List_ByStage_ReturnsOnlyStageCards() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1")); + store.SeedCard(Card("c_2", stage: "work")); + + IReadOnlyList cards = await service.ListSelectedCardsAsync("work", CancellationToken.None); + + CardDto card = Assert.Single(cards); + Assert.Equal("c_2", card.Id); + } + + [Fact] + public async Task Get_Missing_ReturnsNull() + { + (CardsService service, _, _, _) = Create(); + + CardDto? card = await service.GetCardAsync("c_missing", CancellationToken.None); + + Assert.Null(card); + } + + // ─── Ручное создание (create_local_card L103–124, Ruling 6) ───────────── + + [Fact] + public async Task CreateLocal_WithStage_TrimsTitleAndWritesCreatedLocalHistory() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + + CardDto card = await service.CreateLocalCardAsync( + new CardLocalCreateDto(Title: " Задача на бота ", ContainerId: "work"), + CancellationToken.None); + + Assert.True(card.Local); + Assert.Equal("work", card.Col); + Assert.Equal("Задача на бота", card.Title); // title — Trim() (Ruling 6) + CardHistoryDto entry = Assert.Single(card.History); + Assert.StartsWith("h_", entry.Id); + Assert.Equal("createdLocal", entry.Type); + Assert.Null(entry.Stage); + Assert.True(entry.At > 0); + Assert.Single(store.CardDtos); + } + + [Fact] + public async Task CreateLocal_UnknownOrEmptyStage_FallsBackToPlanned() + { + (CardsService service, _, _, _) = Create(); + + CardDto unknown = await service.CreateLocalCardAsync( + new CardLocalCreateDto(Title: "Нет такой стадии", ContainerId: "stuck"), + CancellationToken.None); + CardDto empty = await service.CreateLocalCardAsync( + new CardLocalCreateDto(Title: "Стадии нет"), + CancellationToken.None); + + Assert.Equal("planned", unknown.Col); + Assert.Equal("planned", empty.Col); + Assert.True(unknown.Local); + } + + [Fact] + public async Task CreateLocal_EmptyTitle_Allowed() + { + (CardsService service, _, _, _) = Create(); + + CardDto card = await service.CreateLocalCardAsync(new CardLocalCreateDto(), CancellationToken.None); + + Assert.Equal(string.Empty, card.Title); // фронт шлёт {title:''} — 1:1 прототип (Ruling 6) + Assert.Equal("planned", card.Col); + } + + // ─── «Взять в работу» (take_lead_to_projects L127–156, Ruling 5) ──────── + + [Fact] + public async Task TakeCard_CardMissing_ReturnsNullAndCreatesNothing() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + + CardDto? card = await service.TakeCardAsync("c_missing", CancellationToken.None); + + Assert.Null(card); + Assert.Empty(store.CardDtos); + } + + [Fact] + public async Task TakeCard_MovesCardToPlannedKeepingFields() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card( + "c_1", + stage: "inbox", + title: "Middle Python на бота", + summary: "Компания: X\nФормат: проект", + contact: "@user", + 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 при существующей карточке"); + + // Та же карточка переехала в стадию planned, поля сохранены (Ruling 4/5). + Assert.Equal("c_1", card.Id); + Assert.Equal("planned", card.Col); + Assert.False(card.Local); + Assert.Equal("Middle Python на бота", card.Title); + Assert.Equal("Компания: X\nФормат: проект", card.Summary); + Assert.Equal("@user", card.Contact); + Assert.Equal(new[] { "Python", "aiogram" }, card.Stack); + Assert.Equal(new CardBudgetDto(1600, 2200, "USD"), card.Budget); + // Комментарий-«взял в работу». + CardCommentDto comment = Assert.Single(card.Comments); + Assert.StartsWith("cm_", comment.Id); + Assert.Equal("Вы", comment.By); + Assert.Equal("Взял в работу.", comment.Text); + // История — запись переноса в planned. + CardHistoryDto history = Assert.Single(card.History); + Assert.StartsWith("h_", history.Id); + Assert.Null(history.Type); + Assert.Equal("planned", history.Stage); + Assert.Single(store.CardDtos); + } + + [Fact] + public async Task TakeCard_AlreadyInStage_ReturnsCardUnchanged() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", stage: "work", title: "Уже взята")); + + CardDto? card = await service.TakeCardAsync("c_1", CancellationToken.None); + + // Идемпотентность: карточка уже в стадии — возврат без изменений и без второго комментария. + Assert.NotNull(card); + Assert.Equal("c_1", card!.Id); + Assert.Equal("work", card.Col); + Assert.Empty(card.Comments); + Assert.Single(store.CardDtos); + } + + // ─── Правка полей (patch_card L159–187; presence-aware тело PATCH) ────── + + [Fact] + public async Task Patch_PresentKeys_UpdateFieldsAndBumpUpdatedAt() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card( + "c_1", + title: "Старый заголовок", + summary: "Старое описание", + contact: "@old", + updatedAtMs: 1)); + + CardDto card = await service.PatchCardAsync( + "c_1", + PatchBody( + ("title", "Новый заголовок"), + ("summary", string.Empty), // присутствующий ключ со "" — очистка текста + ("tzText", "ТЗ"), + ("stack", new[] { "C#", ".NET" }), // стек — полная замена массива + ("budget", new { from = 500, cur = "EUR" })), + CancellationToken.None) + ?? throw new InvalidOperationException("patch вернул null при существующей карточке"); + + Assert.Equal("Новый заголовок", card.Title); + Assert.Equal(string.Empty, card.Summary); // summary очищена пустой строкой + Assert.Equal("@old", card.Contact); // ключ contact отсутствует — поле не меняется + Assert.Equal("ТЗ", card.TzText); + Assert.Equal(new[] { "C#", ".NET" }, card.Stack); + Assert.Equal(new CardBudgetDto(500, null, "EUR"), card.Budget); // бюджет из from/to/cur + Assert.True(card.UpdatedAtMs > 1); // bump UpdatedAt + } + + [Fact] + public async Task Patch_BudgetNull_ClearsBudget() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", title: "С бюджетом", updatedAtMs: 1) + with { Budget = new CardBudgetDto(1000, 2000, "USD") }); + + CardDto? card = await service.PatchCardAsync("c_1", PatchBody(("budget", null)), CancellationToken.None); + + // Фронт шлёт явный budget:null для очистки — budget снят (Ruling 11). + Assert.NotNull(card); + Assert.Null(card!.Budget); + CardDto stored = Assert.Single(store.CardDtos); + Assert.Null(stored.Budget); + } + + [Fact] + public async Task Patch_StackNull_ClearsStack() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", title: "С стеком", updatedAtMs: 1) + with { Stack = new[] { "Python" } }); + + CardDto? card = await service.PatchCardAsync("c_1", PatchBody(("stack", null)), CancellationToken.None); + + // Прототип: _json(patch["stack"] or []) — явный null очищает стек (patch_card L172–173). + Assert.NotNull(card); + Assert.Empty(card!.Stack); + } + + [Fact] + public async Task Patch_UnknownKeys_AreIgnored() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", title: "Старый")); + + CardDto? card = await service.PatchCardAsync( + "c_1", + PatchBody(("stage", "finished"), ("title", "Новый")), + CancellationToken.None); + + Assert.NotNull(card); + Assert.Equal("Новый", card!.Title); // известный ключ применён + Assert.Equal("planned", card.Col); // неизвестный ключ (stage) отброшен, как pydantic PatchBody + } + + [Fact] + public async Task Patch_NullTextKey_IsIgnored() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", title: "Старый", summary: "Описание", updatedAtMs: 1)); + + CardDto? card = await service.PatchCardAsync( + "c_1", + PatchBody(("title", null), ("summary", "Новое описание")), + CancellationToken.None); + + // Текстовые поля прототип НЕ очищает null-ом (str(None) — баг); JSON-null трактуется как отсутствие + // ключа — title не тронут, summary обновлён. + Assert.NotNull(card); + Assert.Equal("Старый", card!.Title); + Assert.Equal("Новое описание", card.Summary); + } + + [Fact] + public async Task Patch_CardMissing_ReturnsNull() + { + (CardsService service, _, _, _) = Create(); + + CardDto? card = await service.PatchCardAsync( + "c_missing", + PatchBody(("title", "Т")), + CancellationToken.None); + + Assert.Null(card); + } + + // ─── Move + история + сброс напоминания (move_stage L202–216) ────────── + + [Fact] + public async Task Move_TwoMoves_AppendHistoryResetReminderAndBumpUpdatedAt() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + long seededAt = 5_000; + CardDto before = Card("c_1", stage: "hold", title: "Отложенная", updatedAtMs: seededAt) + with + { + Reminder = new CardReminderDto(seededAt + 1_000), + History = new[] { new CardHistoryDto("h_seed", seededAt, "created", null) }, + }; + store.SeedCard(before); + + CardResultDto first = await service.MoveStageCardAsync("c_1", "work", CancellationToken.None); + CardResultDto second = await service.MoveStageCardAsync("c_1", "review", CancellationToken.None); + CardDto? card = second.Card; + + Assert.Null(first.Error); + Assert.Null(second.Error); + Assert.NotNull(card); + Assert.Equal("review", card!.Col); + Assert.Null(card.Reminder); // любой move сбрасывает напоминание (Ruling 3) + + // История КОПИТСЯ (append, не замена): создание + записи обоих move в порядке переносов (Ruling 7). + Assert.Equal(3, card.History.Count); + Assert.Equal( + new[] { "created", "work", "review" }, + card.History.Select(entry => entry.Stage ?? entry.Type ?? string.Empty)); + Assert.All(card.History, entry => Assert.StartsWith("h_", entry.Id)); + Assert.Equal(card.UpdatedAtMs, card.History[^1].At); // updated_at = время последнего переноса + Assert.True(card.UpdatedAtMs >= seededAt); + Assert.Equal(before.Id, card.Id); + + CardDto stored = Assert.Single(store.CardDtos); + Assert.Equal("review", stored.Col); + Assert.Null(stored.Reminder); + Assert.Equal(3, stored.History.Count); // в хранилище та же накопленная история + } + + [Fact] + public async Task Move_UnknownStage_Returns400ErrorAndLeavesCardUntouched() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", stage: "planned")); + + CardResultDto result = await service.MoveStageCardAsync("c_1", "stuck", CancellationToken.None); + + Assert.Equal(CardsService.UnknownStageDetail, result.Error); + Assert.Null(result.Card); + CardDto stored = Assert.Single(store.CardDtos); + Assert.Equal("planned", stored.Col); + Assert.Empty(stored.History); + } + + [Fact] + public async Task Move_CardMissing_ReturnsNullCard() + { + (CardsService service, _, _, _) = Create(); + + CardResultDto result = await service.MoveStageCardAsync("c_missing", "work", CancellationToken.None); + + Assert.Null(result.Error); + Assert.Null(result.Card); // 404 «Карточка не найдена» — текст у эндпоинта + } + + // ─── Очистка «Отклонено» (clear_stage L223–231, Ruling 9) ─────────────── + + [Fact] + public async Task ClearRejected_RemovesOnlyRejectedAndReturnsCount() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", stage: "rejected")); + store.SeedCard(Card("c_2", stage: "rejected")); + store.SeedCard(Card("c_3", stage: "finished")); + store.SeedCard(Card("c_4", stage: "planned")); + + int cleared = await service.ClearRejectedAsync(CancellationToken.None); + + Assert.Equal(2, cleared); + Assert.Equal(new[] { "c_3", "c_4" }, store.CardDtos.Select(card => card.Id).OrderBy(id => id)); + } + + [Fact] + public async Task ClearRejected_EmptyStage_ReturnsZero() + { + (CardsService service, _, _, _) = Create(); + + int cleared = await service.ClearRejectedAsync(CancellationToken.None); + + Assert.Equal(0, cleared); + } + + // ─── Комментарии (add_comment L194–199, Ruling 11) ───────────────────── + + [Fact] + public async Task AddComment_Valid_AppendsTrimmedCommentWithWireForm() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1)); + + AddCommentResultDto result = await service.AddCommentAsync("c_1", " Перезвонить завтра ", CancellationToken.None); + + Assert.Null(result.Error); + CardCommentDto comment = Assert.Single(result.Comments!); + Assert.StartsWith("cm_", comment.Id); // id комментария — общий префикс карточки + Assert.Equal("Вы", comment.By); + Assert.Equal("Перезвонить завтра", comment.Text); // текст — после Trim + Assert.Equal("только что", comment.Time); + CardDto stored = Assert.Single(store.CardDtos); + Assert.Equal(comment, Assert.Single(stored.Comments)); // комментарий сохранён на карточке + } + + [Fact] + public async Task AddComment_TwoComments_AppendsPreservingOrder() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", title: "Карточка") + with { Comments = new[] { new CardCommentDto("cm_1", "Вы", "Первый", "5 мин") } }); + + AddCommentResultDto result = await service.AddCommentAsync("c_1", "Второй", CancellationToken.None); + + Assert.Null(result.Error); + Assert.NotNull(result.Comments); + Assert.Equal(new[] { "Первый", "Второй" }, result.Comments!.Select(comment => comment.Text)); + Assert.Equal(2, Assert.Single(store.CardDtos).Comments.Count); // в хранилище тот же накопленный список + } + + [Fact] + public async Task AddComment_EmptyOrWhitespaceText_Returns400DetailAndWritesNothing() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", title: "Карточка")); + + AddCommentResultDto empty = await service.AddCommentAsync("c_1", string.Empty, CancellationToken.None); + AddCommentResultDto whitespace = await service.AddCommentAsync("c_1", " ", CancellationToken.None); + + Assert.Equal(CardsService.EmptyCommentDetail, empty.Error); // «Пустой комментарий» + Assert.Equal(CardsService.EmptyCommentDetail, whitespace.Error); + Assert.Null(empty.Comments); + Assert.Empty(Assert.Single(store.CardDtos).Comments); // комментарий не записан + } + + [Fact] + public async Task AddComment_CardMissing_ReturnsNullComments() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + + AddCommentResultDto result = await service.AddCommentAsync("c_missing", "Текст", CancellationToken.None); + + Assert.Null(result.Error); + Assert.Null(result.Comments); // эндпоинт отвечает 404 «Карточка не найдена» + Assert.Empty(store.CardDtos); + } + + // ─── Ссылки (add_link/remove_link L133–150) ──────────────────────────── + + [Fact] + public async Task AddLink_NoScheme_PrefixesHttpsAndDefaultsNameToUrl() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1) + with { Links = new[] { new CardLinkDto("pl_1", "Сайт", "https://example.com") } }); + + CardResultDto result = await service.AddLinkAsync("c_1", string.Empty, " example.com ", CancellationToken.None); + + Assert.Null(result.Error); + Assert.NotNull(result.Card); + Assert.Equal(2, result.Card!.Links.Count); // ссылка дописана в конец существующих + CardLinkDto link = result.Card.Links[^1]; + Assert.StartsWith("pl_", link.Id); + Assert.Equal("https://example.com", link.Url); // без схемы → префикс https:// + Assert.Equal("https://example.com", link.Name); // пустое имя → name = url + Assert.Equal("Сайт", result.Card.Links[0].Name); // прежняя ссылка на месте, порядок сохранён + Assert.True(Assert.Single(store.CardDtos).UpdatedAtMs > 1); // append бампает UpdatedAt + } + + [Fact] + public async Task AddLink_HasHttpScheme_KeepsSchemeAndUsesTrimmedName() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", title: "Карточка")); + + CardResultDto result = await service.AddLinkAsync("c_1", " Сайт ", "http://site.ru/abc", CancellationToken.None); + + Assert.Null(result.Error); + CardLinkDto link = Assert.Single(result.Card!.Links); + Assert.Equal("http://site.ru/abc", link.Url); // схема http сохраняется + Assert.Equal("Сайт", link.Name); // name — после Trim + } + + [Fact] + public async Task AddLink_EmptyUrl_Returns400DetailAndWritesNothing() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", title: "Карточка")); + + CardResultDto result = await service.AddLinkAsync("c_1", "Имя", " ", CancellationToken.None); + + Assert.Equal(CardsService.EmptyLinkDetail, result.Error); // «Пустая ссылка» + Assert.Null(result.Card); + Assert.Empty(Assert.Single(store.CardDtos).Links); // ссылка не записана + } + + [Fact] + public async Task AddLink_CardMissing_ReturnsNullCardBeforeUrlValidation() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + + CardResultDto result = await service.AddLinkAsync("c_missing", string.Empty, "example.com", CancellationToken.None); + + Assert.Null(result.Error); + Assert.Null(result.Card); // 404-семантика: карточки нет раньше валидации url + Assert.Empty(store.CardDtos); + } + + [Fact] + public async Task RemoveLink_ById_RemovesOnlyTargetAndReturnsCard() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", title: "Карточка", updatedAtMs: 1) + with + { + Links = new[] + { + new CardLinkDto("pl_1", "Старая", "https://a.b"), + new CardLinkDto("pl_2", "Новая", "https://c.d"), + }, + }); + + CardResultDto result = await service.RemoveLinkAsync("c_1", "pl_1", CancellationToken.None); + + Assert.Null(result.Error); + CardLinkDto link = Assert.Single(result.Card!.Links); + Assert.Equal("pl_2", link.Id); // удалена только указанная ссылка + CardDto stored = Assert.Single(store.CardDtos); + Assert.Equal("pl_2", Assert.Single(stored.Links).Id); + Assert.True(stored.UpdatedAtMs > 1); // удаление бампает UpdatedAt + } + + [Fact] + public async Task RemoveLink_UnknownLinkId_LeavesLinksUnchangedWithoutError() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + store.SeedCard(Card("c_1", title: "Карточка") + with { Links = new[] { new CardLinkDto("pl_1", "Сайт", "https://a.b") } }); + + CardResultDto result = await service.RemoveLinkAsync("c_1", "pl_ghost", CancellationToken.None); + + Assert.Null(result.Error); + Assert.Equal("pl_1", Assert.Single(result.Card!.Links).Id); // нет ошибки — ссылка просто не найдена + } + + [Fact] + public async Task RemoveLink_CardMissing_ReturnsNullCard() + { + (CardsService service, FakeKanjStore store, _, _) = Create(); + + CardResultDto result = await service.RemoveLinkAsync("c_missing", "pl_1", CancellationToken.None); + + Assert.Null(result.Error); + Assert.Null(result.Card); // эндпоинт отвечает 404 «Карточка не найдена» + Assert.Empty(store.CardDtos); + } + + // ─── Хелперы ────────────────────────────────────────────────────────── + + private static (CardsService Service, FakeKanjStore Store, FakeSettingsStore Settings, FakeMlClient Ml) Create() + { + var store = new FakeKanjStore(); + var settings = new FakeSettingsStore(); + var ml = new FakeMlClient(); + return (new CardsService(store, settings, ml, new FakeFileStorage()), store, settings, ml); + } + + // Тело PATCH из пар «ключ → значение» (presence = наличие пары; null — явный JSON-null). + private static Dictionary PatchBody(params (string Key, object? Value)[] fields) + { + var body = new Dictionary(StringComparer.Ordinal); + foreach ((string key, object? value) in fields) + { + body[key] = JsonSerializer.SerializeToElement(value); + } + + return body; + } + + // Карточка с полями по умолчанию (planned, local=false, CreatedAtMs=1, UpdatedAtMs=1). + private static CardDto Card( + string id, + string stage = "planned", + string title = "", + string summary = "", + string contact = "", + bool local = false, + long updatedAtMs = 1, + IReadOnlyList? stack = null, + CardBudgetDto? budget = null) + { + return new CardDto + { + Id = id, + Col = stage, + Title = title, + Summary = summary, + Contact = contact, + Local = local, + Stack = stack ?? Array.Empty(), + Budget = budget, + CreatedAtMs = 1, + UpdatedAtMs = updatedAtMs, + }; + } +} diff --git a/src/core/tests/Deal.Tests.Unit/CardsServiceTests.cs b/src/core/tests/Deal.Tests.Unit/CardsServiceTests.cs index 7a1330a..1cde99a 100644 --- a/src/core/tests/Deal.Tests.Unit/CardsServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/CardsServiceTests.cs @@ -1,7 +1,10 @@ using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.ColumnRules; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Kanban.Application.ColumnRules; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/ContainersServiceTests.cs b/src/core/tests/Deal.Tests.Unit/ContainersServiceTests.cs index 703e22a..ed05da8 100644 --- a/src/core/tests/Deal.Tests.Unit/ContainersServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/ContainersServiceTests.cs @@ -1,6 +1,9 @@ using System.Text.Json; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/ConversionRecomputerTests.cs b/src/core/tests/Deal.Tests.Unit/ConversionRecomputerTests.cs index 9afca57..e0de743 100644 --- a/src/core/tests/Deal.Tests.Unit/ConversionRecomputerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/ConversionRecomputerTests.cs @@ -1,8 +1,13 @@ using System.Text.Json; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/DataRetentionSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/DataRetentionSchedulerTests.cs index 33fe38b..429b5b2 100644 --- a/src/core/tests/Deal.Tests.Unit/DataRetentionSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/DataRetentionSchedulerTests.cs @@ -1,110 +1,113 @@ -using Deal.Api.Configuration; -using Deal.Api.Hosting; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Tests.Unit; - -/// -/// Тесты логики прохода (этап 12, пакет B): авто-очистка -/// audit_log по retention, сброс накопительных полей tenant_limits прошедших периодов и уборка -/// завершившихся окон распределённых счётчиков. Тайминги цикла не тестируются — итерация через публичный -/// . -/// -/// -/// Скоупы/DI — реальный ServiceCollection с фейками хранилищ (, -/// , ), зеркалящими семантику -/// EF-адаптеров. Срок хранения аудита — . -/// -public sealed class DataRetentionSchedulerTests -{ - // Тенант сценария (строка лимита). - private static readonly Guid TenantId = Guid.NewGuid(); - - // Срок хранения аудита сценария (дней). - private const int RetentionDays = 180; - - [Fact] - public async Task RunCycle_PurgesAgedAuditResetsExpiredLimitsAndDeletesExpiredCounters() - { - DateTimeOffset now = DateTimeOffset.UtcNow; - var audit = new FakeAuditLogStore(); - await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 20))), CancellationToken.None); - await audit.AppendAsync(AuditRecord(AuditEvents.TenantLoginOk, now.AddDays(-1)), CancellationToken.None); - - var limits = new FakeTenantLimitStore(); - // Период месяца начат два месяца назад → истёк; строка с накоплениями должна обнулиться. - limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 700, warned80: true); - - var counters = new FakeRateLimitCounterStore(); - await counters.IncrementAsync("expired", now.AddHours(-2), now.AddHours(-2).AddMinutes(1), 1, CancellationToken.None); - await counters.IncrementAsync("active", now, now.AddMinutes(1), 1, CancellationToken.None); - - await using ServiceProvider provider = BuildProvider(audit, limits, counters); - DataRetentionScheduler scheduler = new( - provider.GetRequiredService(), - new DataRetentionOptions { Enabled = true, AuditRetentionDays = RetentionDays }, - NullLogger.Instance); - - await scheduler.RunCycleAsync(CancellationToken.None); - - // Аудит: старая запись удалена, свежая осталась. - var remainingAudit = Assert.Single(audit.Records); - Assert.Equal(AuditEvents.TenantLoginOk, remainingAudit.EventType); - - // Лимиты: накопления прошедшего периода сброшены. - Assert.Equal(0, limits.UsedTokens(TenantId)); - - // Счётчики: завершившееся окно удалено, активное осталось. - Assert.Equal(0, await counters.GetCountAsync("expired", now.AddHours(-2), CancellationToken.None)); - Assert.Equal(1, await counters.GetCountAsync("active", now, CancellationToken.None)); - } - - /// - /// Повторный проход на тех же данных — идемпотентен (чистить больше нечего). - /// - [Fact] - public async Task RunCycle_IsIdempotent() - { - DateTimeOffset now = DateTimeOffset.UtcNow; - var audit = new FakeAuditLogStore(); - await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None); - var limits = new FakeTenantLimitStore(); - limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 500); - await using ServiceProvider provider = BuildProvider(audit, limits, new FakeRateLimitCounterStore()); - DataRetentionScheduler scheduler = new( - provider.GetRequiredService(), - new DataRetentionOptions { AuditRetentionDays = RetentionDays }, - NullLogger.Instance); - - await scheduler.RunCycleAsync(CancellationToken.None); - await scheduler.RunCycleAsync(CancellationToken.None); - - Assert.Empty(audit.Records); - Assert.Equal(0, limits.UsedTokens(TenantId)); - } - - // Строит DI-провайдер теста: три фейк-хранилища в scope прохода. - // audit: Фейк-хранилище аудита (записи посеяны сценарием). - // limits: Фейк-хранилище лимитов (строки посеяны сценарием). - // counters: Фейк-хранилище счётчиков (окна посеяны сценарием). - // Возвращает: Провайдер с сервисами цикла. - private static ServiceProvider BuildProvider( - FakeAuditLogStore audit, FakeTenantLimitStore limits, FakeRateLimitCounterStore counters) - { - var services = new ServiceCollection(); - services.AddScoped(_ => audit); - services.AddScoped(_ => limits); - services.AddScoped(_ => counters); - return services.BuildServiceProvider(); - } - - // Запись аудита сценария. - // eventType: Тип события. - // at: Момент события (UTC). - // Возвращает: DTO записи аудита. - private static AuditRecordDto AuditRecord(string eventType, DateTimeOffset at) => - new(eventType, AuditActorTypes.Operator, ActorId: null, TenantId: null, Ip: "127.0.0.1", DetailJson: null, at); -} +using Deal.Api.Configuration; +using Deal.Api.Hosting; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Tests.Unit; + +/// +/// Тесты логики прохода (этап 12, пакет B): авто-очистка +/// audit_log по retention, сброс накопительных полей tenant_limits прошедших периодов и уборка +/// завершившихся окон распределённых счётчиков. Тайминги цикла не тестируются — итерация через публичный +/// . +/// +/// +/// Скоупы/DI — реальный ServiceCollection с фейками хранилищ (, +/// , ), зеркалящими семантику +/// EF-адаптеров. Срок хранения аудита — . +/// +public sealed class DataRetentionSchedulerTests +{ + // Тенант сценария (строка лимита). + private static readonly Guid TenantId = Guid.NewGuid(); + + // Срок хранения аудита сценария (дней). + private const int RetentionDays = 180; + + [Fact] + public async Task RunCycle_PurgesAgedAuditResetsExpiredLimitsAndDeletesExpiredCounters() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + var audit = new FakeAuditLogStore(); + await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 20))), CancellationToken.None); + await audit.AppendAsync(AuditRecord(AuditEvents.TenantLoginOk, now.AddDays(-1)), CancellationToken.None); + + var limits = new FakeTenantLimitStore(); + // Период месяца начат два месяца назад → истёк; строка с накоплениями должна обнулиться. + limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 700, warned80: true); + + var counters = new FakeRateLimitCounterStore(); + await counters.IncrementAsync("expired", now.AddHours(-2), now.AddHours(-2).AddMinutes(1), 1, CancellationToken.None); + await counters.IncrementAsync("active", now, now.AddMinutes(1), 1, CancellationToken.None); + + await using ServiceProvider provider = BuildProvider(audit, limits, counters); + DataRetentionScheduler scheduler = new( + provider.GetRequiredService(), + new DataRetentionOptions { Enabled = true, AuditRetentionDays = RetentionDays }, + NullLogger.Instance); + + await scheduler.RunCycleAsync(CancellationToken.None); + + // Аудит: старая запись удалена, свежая осталась. + var remainingAudit = Assert.Single(audit.Records); + Assert.Equal(AuditEvents.TenantLoginOk, remainingAudit.EventType); + + // Лимиты: накопления прошедшего периода сброшены. + Assert.Equal(0, limits.UsedTokens(TenantId)); + + // Счётчики: завершившееся окно удалено, активное осталось. + Assert.Equal(0, await counters.GetCountAsync("expired", now.AddHours(-2), CancellationToken.None)); + Assert.Equal(1, await counters.GetCountAsync("active", now, CancellationToken.None)); + } + + /// + /// Повторный проход на тех же данных — идемпотентен (чистить больше нечего). + /// + [Fact] + public async Task RunCycle_IsIdempotent() + { + DateTimeOffset now = DateTimeOffset.UtcNow; + var audit = new FakeAuditLogStore(); + await audit.AppendAsync(AuditRecord(AuditEvents.OperatorLoginOk, now.AddDays(-(RetentionDays + 1))), CancellationToken.None); + var limits = new FakeTenantLimitStore(); + limits.Preload(TenantId, budgetTokens: 1000, TenantLimitPeriods.Month, now.AddMonths(-2), usedTokens: 500); + await using ServiceProvider provider = BuildProvider(audit, limits, new FakeRateLimitCounterStore()); + DataRetentionScheduler scheduler = new( + provider.GetRequiredService(), + new DataRetentionOptions { AuditRetentionDays = RetentionDays }, + NullLogger.Instance); + + await scheduler.RunCycleAsync(CancellationToken.None); + await scheduler.RunCycleAsync(CancellationToken.None); + + Assert.Empty(audit.Records); + Assert.Equal(0, limits.UsedTokens(TenantId)); + } + + // Строит DI-провайдер теста: три фейк-хранилища в scope прохода. + // audit: Фейк-хранилище аудита (записи посеяны сценарием). + // limits: Фейк-хранилище лимитов (строки посеяны сценарием). + // counters: Фейк-хранилище счётчиков (окна посеяны сценарием). + // Возвращает: Провайдер с сервисами цикла. + private static ServiceProvider BuildProvider( + FakeAuditLogStore audit, FakeTenantLimitStore limits, FakeRateLimitCounterStore counters) + { + var services = new ServiceCollection(); + services.AddScoped(_ => audit); + services.AddScoped(_ => limits); + services.AddScoped(_ => counters); + return services.BuildServiceProvider(); + } + + // Запись аудита сценария. + // eventType: Тип события. + // at: Момент события (UTC). + // Возвращает: DTO записи аудита. + private static AuditRecordDto AuditRecord(string eventType, DateTimeOffset at) => + new(eventType, AuditActorTypes.Operator, ActorId: null, TenantId: null, Ip: "127.0.0.1", DetailJson: null, at); +} diff --git a/src/core/tests/Deal.Tests.Unit/DialogsServiceTests.cs b/src/core/tests/Deal.Tests.Unit/DialogsServiceTests.cs index 6570b78..1a07925 100644 --- a/src/core/tests/Deal.Tests.Unit/DialogsServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/DialogsServiceTests.cs @@ -1,6 +1,9 @@ using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; using Deal.Modules.Telegram.Application; using Deal.Modules.Telegram.Application.Models; using Microsoft.Extensions.Logging; diff --git a/src/core/tests/Deal.Tests.Unit/DiscoveryBanGuardTests.cs b/src/core/tests/Deal.Tests.Unit/DiscoveryBanGuardTests.cs index 6fd71be..f310f12 100644 --- a/src/core/tests/Deal.Tests.Unit/DiscoveryBanGuardTests.cs +++ b/src/core/tests/Deal.Tests.Unit/DiscoveryBanGuardTests.cs @@ -1,5 +1,13 @@ -using Deal.Modules.Discovery.Application; -using Deal.Modules.Settings.Application; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/DiscoveryBlacklistServiceTests.cs b/src/core/tests/Deal.Tests.Unit/DiscoveryBlacklistServiceTests.cs index fe1ee01..83a124a 100644 --- a/src/core/tests/Deal.Tests.Unit/DiscoveryBlacklistServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/DiscoveryBlacklistServiceTests.cs @@ -1,5 +1,9 @@ -using Deal.Modules.Discovery.Application; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/DiscoveryCandidatesServiceTests.cs b/src/core/tests/Deal.Tests.Unit/DiscoveryCandidatesServiceTests.cs index e919c04..b3154cb 100644 --- a/src/core/tests/Deal.Tests.Unit/DiscoveryCandidatesServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/DiscoveryCandidatesServiceTests.cs @@ -1,5 +1,9 @@ -using Deal.Modules.Discovery.Application; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/DiscoveryEvaluatorTests.cs b/src/core/tests/Deal.Tests.Unit/DiscoveryEvaluatorTests.cs index ace1aaa..2c27f37 100644 --- a/src/core/tests/Deal.Tests.Unit/DiscoveryEvaluatorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/DiscoveryEvaluatorTests.cs @@ -1,7 +1,14 @@ using Deal.Contracts.Integrations.Models; -using Deal.Modules.Discovery.Application; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Settings.Application; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/DiscoveryLangDetectorTests.cs b/src/core/tests/Deal.Tests.Unit/DiscoveryLangDetectorTests.cs index d59a0df..48d244b 100644 --- a/src/core/tests/Deal.Tests.Unit/DiscoveryLangDetectorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/DiscoveryLangDetectorTests.cs @@ -1,4 +1,9 @@ -using Deal.Modules.Discovery.Application; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/DiscoveryLogServiceTests.cs b/src/core/tests/Deal.Tests.Unit/DiscoveryLogServiceTests.cs index 7ce7215..9847593 100644 --- a/src/core/tests/Deal.Tests.Unit/DiscoveryLogServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/DiscoveryLogServiceTests.cs @@ -1,5 +1,9 @@ -using Deal.Modules.Discovery.Application; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/DiscoverySearchErrorCounterTests.cs b/src/core/tests/Deal.Tests.Unit/DiscoverySearchErrorCounterTests.cs index 134c143..c7536f7 100644 --- a/src/core/tests/Deal.Tests.Unit/DiscoverySearchErrorCounterTests.cs +++ b/src/core/tests/Deal.Tests.Unit/DiscoverySearchErrorCounterTests.cs @@ -1,4 +1,9 @@ -using Deal.Modules.Discovery.Application; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/DiscoveryTasksServiceTests.cs b/src/core/tests/Deal.Tests.Unit/DiscoveryTasksServiceTests.cs index ba13633..b6cdf06 100644 --- a/src/core/tests/Deal.Tests.Unit/DiscoveryTasksServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/DiscoveryTasksServiceTests.cs @@ -1,5 +1,9 @@ -using Deal.Modules.Discovery.Application; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/DiscoveryWorkerSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/DiscoveryWorkerSchedulerTests.cs index f8a2a5e..8213b75 100644 --- a/src/core/tests/Deal.Tests.Unit/DiscoveryWorkerSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/DiscoveryWorkerSchedulerTests.cs @@ -2,11 +2,21 @@ using Deal.Api.Hosting; using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; using Deal.Infrastructure.Data; -using Deal.Modules.Discovery.Application; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Settings.Application; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; diff --git a/src/core/tests/Deal.Tests.Unit/DiscoveryWorkerServiceTests.cs b/src/core/tests/Deal.Tests.Unit/DiscoveryWorkerServiceTests.cs index cbff9ea..cd1f241 100644 --- a/src/core/tests/Deal.Tests.Unit/DiscoveryWorkerServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/DiscoveryWorkerServiceTests.cs @@ -1,7 +1,14 @@ using Deal.Contracts.Integrations.Models; -using Deal.Modules.Discovery.Application; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; using Deal.Modules.Discovery.Application.Models; -using Deal.Modules.Settings.Application; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; using Grpc.Core; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/FailingTenantProvisioner.cs b/src/core/tests/Deal.Tests.Unit/FailingTenantProvisioner.cs index 4cad574..ae8045c 100644 --- a/src/core/tests/Deal.Tests.Unit/FailingTenantProvisioner.cs +++ b/src/core/tests/Deal.Tests.Unit/FailingTenantProvisioner.cs @@ -1,4 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/FakeAuditLogStore.cs b/src/core/tests/Deal.Tests.Unit/FakeAuditLogStore.cs index 81e9f12..4b84e91 100644 --- a/src/core/tests/Deal.Tests.Unit/FakeAuditLogStore.cs +++ b/src/core/tests/Deal.Tests.Unit/FakeAuditLogStore.cs @@ -1,5 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/FakeAuthStore.cs b/src/core/tests/Deal.Tests.Unit/FakeAuthStore.cs index 8798d78..a70d6d2 100644 --- a/src/core/tests/Deal.Tests.Unit/FakeAuthStore.cs +++ b/src/core/tests/Deal.Tests.Unit/FakeAuthStore.cs @@ -1,5 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/FakeDiscoveryPacer.cs b/src/core/tests/Deal.Tests.Unit/FakeDiscoveryPacer.cs index f264ff0..bc03fa9 100644 --- a/src/core/tests/Deal.Tests.Unit/FakeDiscoveryPacer.cs +++ b/src/core/tests/Deal.Tests.Unit/FakeDiscoveryPacer.cs @@ -1,4 +1,9 @@ -using Deal.Modules.Discovery.Application; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; +using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/FakeDiscoveryStore.cs b/src/core/tests/Deal.Tests.Unit/FakeDiscoveryStore.cs index 5f7137b..2f87d76 100644 --- a/src/core/tests/Deal.Tests.Unit/FakeDiscoveryStore.cs +++ b/src/core/tests/Deal.Tests.Unit/FakeDiscoveryStore.cs @@ -1,5 +1,9 @@ -using Deal.Modules.Discovery.Application; +using Deal.Modules.Discovery.Application.Abstractions; +using Deal.Modules.Discovery.Application.Exceptions; +using Deal.Modules.Discovery.Application.Extensions; using Deal.Modules.Discovery.Application.Models; +using Deal.Modules.Discovery.Application.Registrars; +using Deal.Modules.Discovery.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/FakeGlobalSettingsStore.cs b/src/core/tests/Deal.Tests.Unit/FakeGlobalSettingsStore.cs index 5c0de71..cba670d 100644 --- a/src/core/tests/Deal.Tests.Unit/FakeGlobalSettingsStore.cs +++ b/src/core/tests/Deal.Tests.Unit/FakeGlobalSettingsStore.cs @@ -1,55 +1,57 @@ -using Deal.Modules.Settings.Application; -using Deal.Modules.Settings.Application.Models; - -namespace Deal.Tests.Unit; - -/// -/// In-memory реализация для юнит-тестов глобальных настроек. -/// -/// -/// Как EF-адаптер GlobalSettingsStore, оперирует готовыми JSON-строками (сериализацию выполняет -/// владелец ключа). / позволяют тестам проверять, -/// что именно (и в каком виде — например, enc:) ушло в хранилище. -/// -public sealed class FakeGlobalSettingsStore : IGlobalSettingsStore -{ - private readonly Dictionary _rows = new(StringComparer.Ordinal); - - /// - /// Ключи сохранённых строк (копия на момент обращения). - /// - public IReadOnlyCollection Keys => _rows.Keys.ToList(); - - /// - /// Кладёт готовую строку (сценарий «значение уже сохранено в БД»). - /// - /// Ключ настройки. - /// Значение, сериализованное в JSON. - public void Preload(string key, string valueJson) - { - _rows[key] = new SettingValue(key, valueJson, DateTimeOffset.UtcNow); - } - - /// - /// Возвращает JSON сохранённого значения или null, если ключа нет. - /// - /// Ключ настройки. - /// JSON-строка значения или null. - public string? GetStoredJson(string key) - { - return _rows.TryGetValue(key, out SettingValue? row) ? row.ValueJson : null; - } - - /// - public Task GetAsync(string key, CancellationToken ct) - { - return Task.FromResult(_rows.TryGetValue(key, out SettingValue? row) ? row : null); - } - - /// - public Task SetAsync(string key, string valueJson, CancellationToken ct) - { - _rows[key] = new SettingValue(key, valueJson, DateTimeOffset.UtcNow); - return Task.CompletedTask; - } -} +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; + +namespace Deal.Tests.Unit; + +/// +/// In-memory реализация для юнит-тестов глобальных настроек. +/// +/// +/// Как EF-адаптер GlobalSettingsStore, оперирует готовыми JSON-строками (сериализацию выполняет +/// владелец ключа). / позволяют тестам проверять, +/// что именно (и в каком виде — например, enc:) ушло в хранилище. +/// +public sealed class FakeGlobalSettingsStore : IGlobalSettingsStore +{ + private readonly Dictionary _rows = new(StringComparer.Ordinal); + + /// + /// Ключи сохранённых строк (копия на момент обращения). + /// + public IReadOnlyCollection Keys => _rows.Keys.ToList(); + + /// + /// Кладёт готовую строку (сценарий «значение уже сохранено в БД»). + /// + /// Ключ настройки. + /// Значение, сериализованное в JSON. + public void Preload(string key, string valueJson) + { + _rows[key] = new SettingValue(key, valueJson, DateTimeOffset.UtcNow); + } + + /// + /// Возвращает JSON сохранённого значения или null, если ключа нет. + /// + /// Ключ настройки. + /// JSON-строка значения или null. + public string? GetStoredJson(string key) + { + return _rows.TryGetValue(key, out SettingValue? row) ? row.ValueJson : null; + } + + /// + public Task GetAsync(string key, CancellationToken ct) + { + return Task.FromResult(_rows.TryGetValue(key, out SettingValue? row) ? row : null); + } + + /// + public Task SetAsync(string key, string valueJson, CancellationToken ct) + { + _rows[key] = new SettingValue(key, valueJson, DateTimeOffset.UtcNow); + return Task.CompletedTask; + } +} diff --git a/src/core/tests/Deal.Tests.Unit/FakeInviteStore.cs b/src/core/tests/Deal.Tests.Unit/FakeInviteStore.cs index f813ac2..5e5c012 100644 --- a/src/core/tests/Deal.Tests.Unit/FakeInviteStore.cs +++ b/src/core/tests/Deal.Tests.Unit/FakeInviteStore.cs @@ -1,5 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/FakeKanjStore.cs b/src/core/tests/Deal.Tests.Unit/FakeKanjStore.cs index 70bbcd3..dd5b9c7 100644 --- a/src/core/tests/Deal.Tests.Unit/FakeKanjStore.cs +++ b/src/core/tests/Deal.Tests.Unit/FakeKanjStore.cs @@ -1,8 +1,11 @@ using Deal.Modules.Cards.Application.Abstractions; using Deal.Modules.Cards.Application.Dtos; using Deal.Modules.Cards.Application.Models; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/FakeMlLearningStore.cs b/src/core/tests/Deal.Tests.Unit/FakeMlLearningStore.cs index 7284d5f..5d89978 100644 --- a/src/core/tests/Deal.Tests.Unit/FakeMlLearningStore.cs +++ b/src/core/tests/Deal.Tests.Unit/FakeMlLearningStore.cs @@ -1,5 +1,8 @@ -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/FakeOperatorAuthStore.cs b/src/core/tests/Deal.Tests.Unit/FakeOperatorAuthStore.cs index 0b052a0..adce295 100644 --- a/src/core/tests/Deal.Tests.Unit/FakeOperatorAuthStore.cs +++ b/src/core/tests/Deal.Tests.Unit/FakeOperatorAuthStore.cs @@ -1,5 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/FakePasswordHasher.cs b/src/core/tests/Deal.Tests.Unit/FakePasswordHasher.cs index b065eef..76e64b6 100644 --- a/src/core/tests/Deal.Tests.Unit/FakePasswordHasher.cs +++ b/src/core/tests/Deal.Tests.Unit/FakePasswordHasher.cs @@ -1,4 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/FakePipelineStore.cs b/src/core/tests/Deal.Tests.Unit/FakePipelineStore.cs index bee3ee3..eb9ee53 100644 --- a/src/core/tests/Deal.Tests.Unit/FakePipelineStore.cs +++ b/src/core/tests/Deal.Tests.Unit/FakePipelineStore.cs @@ -1,6 +1,12 @@ -using Deal.Modules.Kanban.Application; -using Deal.Modules.Pipeline.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/FakeRateLimitCounterStore.cs b/src/core/tests/Deal.Tests.Unit/FakeRateLimitCounterStore.cs index 8c5cbcd..4b5eff8 100644 --- a/src/core/tests/Deal.Tests.Unit/FakeRateLimitCounterStore.cs +++ b/src/core/tests/Deal.Tests.Unit/FakeRateLimitCounterStore.cs @@ -1,4 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/FakeRatesListener.cs b/src/core/tests/Deal.Tests.Unit/FakeRatesListener.cs index ba27d29..61819f8 100644 --- a/src/core/tests/Deal.Tests.Unit/FakeRatesListener.cs +++ b/src/core/tests/Deal.Tests.Unit/FakeRatesListener.cs @@ -1,4 +1,7 @@ -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/FakeRatesSource.cs b/src/core/tests/Deal.Tests.Unit/FakeRatesSource.cs index 937cdc8..9004073 100644 --- a/src/core/tests/Deal.Tests.Unit/FakeRatesSource.cs +++ b/src/core/tests/Deal.Tests.Unit/FakeRatesSource.cs @@ -1,4 +1,7 @@ -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/FakeSecretCipher.cs b/src/core/tests/Deal.Tests.Unit/FakeSecretCipher.cs index a2ca50a..255a9f2 100644 --- a/src/core/tests/Deal.Tests.Unit/FakeSecretCipher.cs +++ b/src/core/tests/Deal.Tests.Unit/FakeSecretCipher.cs @@ -1,5 +1,8 @@ using System.Text; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/FakeSettingsStore.cs b/src/core/tests/Deal.Tests.Unit/FakeSettingsStore.cs index f46a740..82b6079 100644 --- a/src/core/tests/Deal.Tests.Unit/FakeSettingsStore.cs +++ b/src/core/tests/Deal.Tests.Unit/FakeSettingsStore.cs @@ -1,5 +1,7 @@ -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/FakeTenantLimitStore.cs b/src/core/tests/Deal.Tests.Unit/FakeTenantLimitStore.cs index df70a7a..a1902d2 100644 --- a/src/core/tests/Deal.Tests.Unit/FakeTenantLimitStore.cs +++ b/src/core/tests/Deal.Tests.Unit/FakeTenantLimitStore.cs @@ -1,5 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/FakeTenantProvisioner.cs b/src/core/tests/Deal.Tests.Unit/FakeTenantProvisioner.cs index edb6bb5..f235d79 100644 --- a/src/core/tests/Deal.Tests.Unit/FakeTenantProvisioner.cs +++ b/src/core/tests/Deal.Tests.Unit/FakeTenantProvisioner.cs @@ -1,4 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/FakeTenantRegistry.cs b/src/core/tests/Deal.Tests.Unit/FakeTenantRegistry.cs index d03dc57..fdde73f 100644 --- a/src/core/tests/Deal.Tests.Unit/FakeTenantRegistry.cs +++ b/src/core/tests/Deal.Tests.Unit/FakeTenantRegistry.cs @@ -1,5 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/FakeTenantRepository.cs b/src/core/tests/Deal.Tests.Unit/FakeTenantRepository.cs index c1309fd..770c930 100644 --- a/src/core/tests/Deal.Tests.Unit/FakeTenantRepository.cs +++ b/src/core/tests/Deal.Tests.Unit/FakeTenantRepository.cs @@ -1,5 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/FakeTenantStore.cs b/src/core/tests/Deal.Tests.Unit/FakeTenantStore.cs index 31c8bc3..538f455 100644 --- a/src/core/tests/Deal.Tests.Unit/FakeTenantStore.cs +++ b/src/core/tests/Deal.Tests.Unit/FakeTenantStore.cs @@ -1,5 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/FakeTokenUsageEventStore.cs b/src/core/tests/Deal.Tests.Unit/FakeTokenUsageEventStore.cs index 43d1244..6ce2043 100644 --- a/src/core/tests/Deal.Tests.Unit/FakeTokenUsageEventStore.cs +++ b/src/core/tests/Deal.Tests.Unit/FakeTokenUsageEventStore.cs @@ -1,77 +1,80 @@ -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Tests.Unit; - -/// -/// In-memory реализация для юнит/HTTP-тестов (этап 10, T2). -/// -/// -/// Повторяет семантику EF-адаптера: append-only запись и агрегация по day/tenant/provider/model с фильтрами -/// TenantId/Provider/Model/Kind/At-range. Порядок: day — по возрастанию даты, остальные — по убыванию total. -/// Update/Delete отсутствуют (порт append-only). -/// -public sealed class FakeTokenUsageEventStore : ITokenUsageEventStore -{ - private readonly List _records = []; - - /// - /// Записи хранилища в порядке добавления. - /// - public IReadOnlyList Records => _records; - - /// - public Task AppendAsync(TokenUsageEventDto record, CancellationToken ct) - { - _records.Add(record); - return Task.CompletedTask; - } - - /// - public Task> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct) - { - IEnumerable filtered = _records.Where(record => - (query.TenantId is null || record.TenantId == query.TenantId) && - (string.IsNullOrWhiteSpace(query.Provider) || record.Provider == query.Provider) && - (string.IsNullOrWhiteSpace(query.Model) || record.Model == query.Model) && - (string.IsNullOrWhiteSpace(query.Kind) || record.Kind == query.Kind) && - (query.From is null || record.At >= query.From.Value) && - (query.To is null || record.At <= query.To.Value)); - - IReadOnlyList result = query.GroupBy switch - { - TokenUsageGroupBys.Day => GroupByDay(filtered), - TokenUsageGroupBys.Tenant => GroupByString(filtered, record => record.TenantId.ToString("D")), - TokenUsageGroupBys.Provider => GroupByString(filtered, record => record.Provider), - TokenUsageGroupBys.Model => GroupByString(filtered, record => record.Model), - _ => throw new ArgumentException($"Неизвестная группировка: '{query.GroupBy}'.", nameof(query)), - }; - return Task.FromResult(result); - } - - private static IReadOnlyList GroupByDay(IEnumerable source) => - source - .GroupBy(record => record.At.UtcDateTime.Date) - .OrderBy(group => group.Key) - .Select(group => ToAggregate(group.Key.ToString("yyyy-MM-dd"), group)) - .ToList(); - - private static IReadOnlyList GroupByString( - IEnumerable source, Func keySelector) => - source - .GroupBy(keySelector) - .Select(group => ToAggregate(group.Key, group)) - .OrderByDescending(row => row.TotalTokens) - .ToList(); - - private static TokenUsageAggregateDto ToAggregate(string key, IEnumerable group) - { - List rows = group.ToList(); - return new TokenUsageAggregateDto( - key, - rows.Sum(row => row.PromptTokens), - rows.Sum(row => row.CompletionTokens), - rows.Sum(row => row.TotalTokens), - rows.Count); - } -} +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Tests.Unit; + +/// +/// In-memory реализация для юнит/HTTP-тестов (этап 10, T2). +/// +/// +/// Повторяет семантику EF-адаптера: append-only запись и агрегация по day/tenant/provider/model с фильтрами +/// TenantId/Provider/Model/Kind/At-range. Порядок: day — по возрастанию даты, остальные — по убыванию total. +/// Update/Delete отсутствуют (порт append-only). +/// +public sealed class FakeTokenUsageEventStore : ITokenUsageEventStore +{ + private readonly List _records = []; + + /// + /// Записи хранилища в порядке добавления. + /// + public IReadOnlyList Records => _records; + + /// + public Task AppendAsync(TokenUsageEventDto record, CancellationToken ct) + { + _records.Add(record); + return Task.CompletedTask; + } + + /// + public Task> AggregateAsync(TokenUsageEventQueryDto query, CancellationToken ct) + { + IEnumerable filtered = _records.Where(record => + (query.TenantId is null || record.TenantId == query.TenantId) && + (string.IsNullOrWhiteSpace(query.Provider) || record.Provider == query.Provider) && + (string.IsNullOrWhiteSpace(query.Model) || record.Model == query.Model) && + (string.IsNullOrWhiteSpace(query.Kind) || record.Kind == query.Kind) && + (query.From is null || record.At >= query.From.Value) && + (query.To is null || record.At <= query.To.Value)); + + IReadOnlyList result = query.GroupBy switch + { + TokenUsageGroupBys.Day => GroupByDay(filtered), + TokenUsageGroupBys.Tenant => GroupByString(filtered, record => record.TenantId.ToString("D")), + TokenUsageGroupBys.Provider => GroupByString(filtered, record => record.Provider), + TokenUsageGroupBys.Model => GroupByString(filtered, record => record.Model), + _ => throw new ArgumentException($"Неизвестная группировка: '{query.GroupBy}'.", nameof(query)), + }; + return Task.FromResult(result); + } + + private static IReadOnlyList GroupByDay(IEnumerable source) => + source + .GroupBy(record => record.At.UtcDateTime.Date) + .OrderBy(group => group.Key) + .Select(group => ToAggregate(group.Key.ToString("yyyy-MM-dd"), group)) + .ToList(); + + private static IReadOnlyList GroupByString( + IEnumerable source, Func keySelector) => + source + .GroupBy(keySelector) + .Select(group => ToAggregate(group.Key, group)) + .OrderByDescending(row => row.TotalTokens) + .ToList(); + + private static TokenUsageAggregateDto ToAggregate(string key, IEnumerable group) + { + List rows = group.ToList(); + return new TokenUsageAggregateDto( + key, + rows.Sum(row => row.PromptTokens), + rows.Sum(row => row.CompletionTokens), + rows.Sum(row => row.TotalTokens), + rows.Count); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/FileKindDetectorTests.cs b/src/core/tests/Deal.Tests.Unit/FileKindDetectorTests.cs index fd993aa..68a38d7 100644 --- a/src/core/tests/Deal.Tests.Unit/FileKindDetectorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/FileKindDetectorTests.cs @@ -1,4 +1,8 @@ -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/GlobalExclusionRulesTests.cs b/src/core/tests/Deal.Tests.Unit/GlobalExclusionRulesTests.cs index 0bc642d..dca9377 100644 --- a/src/core/tests/Deal.Tests.Unit/GlobalExclusionRulesTests.cs +++ b/src/core/tests/Deal.Tests.Unit/GlobalExclusionRulesTests.cs @@ -1,104 +1,106 @@ -using Deal.Modules.Pipeline.Application; -using Deal.Modules.Pipeline.Application.Models; - -namespace Deal.Tests.Unit; - -/// -/// Тесты глобальных исключений (§5.14): слова/локации/типы/бюджет — чистая проверка до ML/ИИ. -/// -public sealed class GlobalExclusionRulesTests -{ - private static GlobalExcludeSettings Settings( - string[]? keywords = null, - string[]? locations = null, - string[]? types = null, - double? budgetFrom = null, - double? budgetTo = null) => - new( - keywords ?? Array.Empty(), - locations ?? Array.Empty(), - types ?? Array.Empty(), - budgetFrom, - budgetTo); - - [Fact] - public void Match_Keyword_ReturnsKeywordFinding() - { - GlobalExclusionResult? result = GlobalExclusionRules.Match( - "Продам крипто-курс, пассивный доход", Settings(keywords: new[] { "крипт" })); - - Assert.NotNull(result); - Assert.Equal(GlobalExclusionRules.KindKeywords, result!.Kind); - Assert.Equal("крипт", result.Kw); - } - - [Fact] - public void Match_Keyword_CaseInsensitive() - { - GlobalExclusionResult? result = GlobalExclusionRules.Match( - "PROMO Krypto project", Settings(keywords: new[] { "krypto" })); - - Assert.NotNull(result); - Assert.Equal(GlobalExclusionRules.KindKeywords, result!.Kind); - } - - [Fact] - public void Match_Location_ReturnsLocationFinding() - { - GlobalExclusionResult? result = GlobalExclusionRules.Match( - "Работа в Милане, офис", Settings(locations: new[] { "милан" })); - - Assert.NotNull(result); - Assert.Equal(GlobalExclusionRules.KindLocation, result!.Kind); - } - - [Fact] - public void Match_Type_ExpandsSynonyms() - { - GlobalExclusionResult? result = GlobalExclusionRules.Match( - "Вакансия: python-разработчик", Settings(types: new[] { "vacancy" })); - - Assert.NotNull(result); - Assert.Equal(GlobalExclusionRules.KindType, result!.Kind); - Assert.Equal("vacancy", result.Kw); - } - - [Fact] - public void Match_BudgetInRange_ReturnsBudgetFinding() - { - GlobalExclusionResult? result = GlobalExclusionRules.Match( - "Проект на 3000$ — срочно", Settings(budgetFrom: 1000, budgetTo: 5000)); - - Assert.NotNull(result); - Assert.Equal(GlobalExclusionRules.KindBudget, result!.Kind); - } - - [Fact] - public void Match_BudgetOutOfRange_ReturnsNull() - { - GlobalExclusionResult? result = GlobalExclusionRules.Match( - "Проект на 9000$ — срочно", Settings(budgetFrom: 1000, budgetTo: 5000)); - - Assert.Null(result); - } - - [Fact] - public void Match_NoRules_ReturnsNull() - { - GlobalExclusionResult? result = GlobalExclusionRules.Match( - "Обычное сообщение про разработку", Settings()); - - Assert.Null(result); - } - - [Fact] - public void Match_KeywordTakesPrecedenceOverLocation() - { - GlobalExclusionResult? result = GlobalExclusionRules.Match( - "Крипто-вакансия в Милане", - Settings(keywords: new[] { "крипто" }, locations: new[] { "милан" })); - - Assert.NotNull(result); - Assert.Equal(GlobalExclusionRules.KindKeywords, result!.Kind); - } -} +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; + +namespace Deal.Tests.Unit; + +/// +/// Тесты глобальных исключений (§5.14): слова/локации/типы/бюджет — чистая проверка до ML/ИИ. +/// +public sealed class GlobalExclusionRulesTests +{ + private static GlobalExcludeSettings Settings( + string[]? keywords = null, + string[]? locations = null, + string[]? types = null, + double? budgetFrom = null, + double? budgetTo = null) => + new( + keywords ?? Array.Empty(), + locations ?? Array.Empty(), + types ?? Array.Empty(), + budgetFrom, + budgetTo); + + [Fact] + public void Match_Keyword_ReturnsKeywordFinding() + { + GlobalExclusionResult? result = GlobalExclusionRules.Match( + "Продам крипто-курс, пассивный доход", Settings(keywords: new[] { "крипт" })); + + Assert.NotNull(result); + Assert.Equal(GlobalExclusionRules.KindKeywords, result!.Kind); + Assert.Equal("крипт", result.Kw); + } + + [Fact] + public void Match_Keyword_CaseInsensitive() + { + GlobalExclusionResult? result = GlobalExclusionRules.Match( + "PROMO Krypto project", Settings(keywords: new[] { "krypto" })); + + Assert.NotNull(result); + Assert.Equal(GlobalExclusionRules.KindKeywords, result!.Kind); + } + + [Fact] + public void Match_Location_ReturnsLocationFinding() + { + GlobalExclusionResult? result = GlobalExclusionRules.Match( + "Работа в Милане, офис", Settings(locations: new[] { "милан" })); + + Assert.NotNull(result); + Assert.Equal(GlobalExclusionRules.KindLocation, result!.Kind); + } + + [Fact] + public void Match_Type_ExpandsSynonyms() + { + GlobalExclusionResult? result = GlobalExclusionRules.Match( + "Вакансия: python-разработчик", Settings(types: new[] { "vacancy" })); + + Assert.NotNull(result); + Assert.Equal(GlobalExclusionRules.KindType, result!.Kind); + Assert.Equal("vacancy", result.Kw); + } + + [Fact] + public void Match_BudgetInRange_ReturnsBudgetFinding() + { + GlobalExclusionResult? result = GlobalExclusionRules.Match( + "Проект на 3000$ — срочно", Settings(budgetFrom: 1000, budgetTo: 5000)); + + Assert.NotNull(result); + Assert.Equal(GlobalExclusionRules.KindBudget, result!.Kind); + } + + [Fact] + public void Match_BudgetOutOfRange_ReturnsNull() + { + GlobalExclusionResult? result = GlobalExclusionRules.Match( + "Проект на 9000$ — срочно", Settings(budgetFrom: 1000, budgetTo: 5000)); + + Assert.Null(result); + } + + [Fact] + public void Match_NoRules_ReturnsNull() + { + GlobalExclusionResult? result = GlobalExclusionRules.Match( + "Обычное сообщение про разработку", Settings()); + + Assert.Null(result); + } + + [Fact] + public void Match_KeywordTakesPrecedenceOverLocation() + { + GlobalExclusionResult? result = GlobalExclusionRules.Match( + "Крипто-вакансия в Милане", + Settings(keywords: new[] { "крипто" }, locations: new[] { "милан" })); + + Assert.NotNull(result); + Assert.Equal(GlobalExclusionRules.KindKeywords, result!.Kind); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/GlobalSettingsStoreTests.cs b/src/core/tests/Deal.Tests.Unit/GlobalSettingsStoreTests.cs index 4ca737c..91f5054 100644 --- a/src/core/tests/Deal.Tests.Unit/GlobalSettingsStoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/GlobalSettingsStoreTests.cs @@ -1,61 +1,63 @@ -using Deal.Infrastructure.Persistence; -using Deal.Infrastructure.Persistence.Repositories; -using Deal.Modules.Settings.Application; -using Deal.Modules.Settings.Application.Models; -using Microsoft.EntityFrameworkCore; - -namespace Deal.Tests.Unit; - -/// -/// Юнит-тесты EF-адаптера на InMemory-провайдере (ТЗ §4.1/§8.1): -/// чтение отсутствующего ключа, upsert (создание/обновление), сохранение строки как есть. -/// -/// -/// Проверяется семантика адаптера; реальный Postgres (схема public) — в dev-приёмке. -/// -public sealed class GlobalSettingsStoreTests -{ - [Fact] - public async Task GetAsync_MissingKey_ReturnsNull() - { - GlobalSettingsStore store = CreateStore(); - - SettingValue? value = await store.GetAsync(GlobalSettingsKeys.TelegramKeys, CancellationToken.None); - - Assert.Null(value); - } - - [Fact] - public async Task SetAsync_NewKey_CreatesRowWithUtcTimestamp() - { - GlobalSettingsStore store = CreateStore(); - - await store.SetAsync(GlobalSettingsKeys.TelegramKeys, "{\"apiId\":\"1234567\"}", CancellationToken.None); - - SettingValue value = (await store.GetAsync(GlobalSettingsKeys.TelegramKeys, CancellationToken.None))!; - Assert.Equal("{\"apiId\":\"1234567\"}", value.ValueJson); - Assert.True(value.UpdatedAt > DateTimeOffset.UtcNow.AddMinutes(-1)); - } - - [Fact] - public async Task SetAsync_ExistingKey_OverwritesValue() - { - GlobalSettingsStore store = CreateStore(); - await store.SetAsync(GlobalSettingsKeys.TelegramKeys, "{\"apiId\":\"1234567\"}", CancellationToken.None); - - await store.SetAsync(GlobalSettingsKeys.TelegramKeys, "{\"apiId\":\"7654321\"}", CancellationToken.None); - - SettingValue value = (await store.GetAsync(GlobalSettingsKeys.TelegramKeys, CancellationToken.None))!; - Assert.Equal("{\"apiId\":\"7654321\"}", value.ValueJson); - } - - // Создаёт адаптер на свежем InMemory-контексте. - // Возвращает: Адаптер глобального KV-хранилища. - private static GlobalSettingsStore CreateStore() - { - var options = new DbContextOptionsBuilder() - .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) - .Options; - return new GlobalSettingsStore(new DealDbContext(options)); - } -} +using Deal.Infrastructure.Persistence; +using Deal.Infrastructure.Persistence.Repositories; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Microsoft.EntityFrameworkCore; + +namespace Deal.Tests.Unit; + +/// +/// Юнит-тесты EF-адаптера на InMemory-провайдере (ТЗ §4.1/§8.1): +/// чтение отсутствующего ключа, upsert (создание/обновление), сохранение строки как есть. +/// +/// +/// Проверяется семантика адаптера; реальный Postgres (схема public) — в dev-приёмке. +/// +public sealed class GlobalSettingsStoreTests +{ + [Fact] + public async Task GetAsync_MissingKey_ReturnsNull() + { + GlobalSettingsStore store = CreateStore(); + + SettingValue? value = await store.GetAsync(GlobalSettingsKeys.TelegramKeys, CancellationToken.None); + + Assert.Null(value); + } + + [Fact] + public async Task SetAsync_NewKey_CreatesRowWithUtcTimestamp() + { + GlobalSettingsStore store = CreateStore(); + + await store.SetAsync(GlobalSettingsKeys.TelegramKeys, "{\"apiId\":\"1234567\"}", CancellationToken.None); + + SettingValue value = (await store.GetAsync(GlobalSettingsKeys.TelegramKeys, CancellationToken.None))!; + Assert.Equal("{\"apiId\":\"1234567\"}", value.ValueJson); + Assert.True(value.UpdatedAt > DateTimeOffset.UtcNow.AddMinutes(-1)); + } + + [Fact] + public async Task SetAsync_ExistingKey_OverwritesValue() + { + GlobalSettingsStore store = CreateStore(); + await store.SetAsync(GlobalSettingsKeys.TelegramKeys, "{\"apiId\":\"1234567\"}", CancellationToken.None); + + await store.SetAsync(GlobalSettingsKeys.TelegramKeys, "{\"apiId\":\"7654321\"}", CancellationToken.None); + + SettingValue value = (await store.GetAsync(GlobalSettingsKeys.TelegramKeys, CancellationToken.None))!; + Assert.Equal("{\"apiId\":\"7654321\"}", value.ValueJson); + } + + // Создаёт адаптер на свежем InMemory-контексте. + // Возвращает: Адаптер глобального KV-хранилища. + private static GlobalSettingsStore CreateStore() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase(Guid.NewGuid().ToString("N")) + .Options; + return new GlobalSettingsStore(new DealDbContext(options)); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/GrpcAiClassifierTests.cs b/src/core/tests/Deal.Tests.Unit/GrpcAiClassifierTests.cs index 6e32f90..6e12775 100644 --- a/src/core/tests/Deal.Tests.Unit/GrpcAiClassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/GrpcAiClassifierTests.cs @@ -6,9 +6,19 @@ using Deal.Grpc.Ai; using Deal.Infrastructure.Data; using Deal.Infrastructure.Integrations; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application; -using Deal.Modules.Settings.Application; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants; using Microsoft.Extensions.Logging.Abstractions; diff --git a/src/core/tests/Deal.Tests.Unit/GrpcAiToolsTests.cs b/src/core/tests/Deal.Tests.Unit/GrpcAiToolsTests.cs index 76765b2..d0dee88 100644 --- a/src/core/tests/Deal.Tests.Unit/GrpcAiToolsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/GrpcAiToolsTests.cs @@ -3,8 +3,15 @@ using Deal.Contracts.Integrations.Models; using Deal.Grpc.Ai; using Deal.Infrastructure.Data; using Deal.Infrastructure.Integrations; -using Deal.Modules.Settings.Application; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants; using Microsoft.Extensions.Logging.Abstractions; diff --git a/src/core/tests/Deal.Tests.Unit/GrpcMlClientTests.cs b/src/core/tests/Deal.Tests.Unit/GrpcMlClientTests.cs index 604aff7..a3fda3c 100644 --- a/src/core/tests/Deal.Tests.Unit/GrpcMlClientTests.cs +++ b/src/core/tests/Deal.Tests.Unit/GrpcMlClientTests.cs @@ -3,8 +3,15 @@ using Deal.Grpc.Ml; using Deal.Infrastructure.Data; using Deal.Infrastructure.Integrations; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants; using Microsoft.Extensions.Logging.Abstractions; diff --git a/src/core/tests/Deal.Tests.Unit/IncomingRulesTests.cs b/src/core/tests/Deal.Tests.Unit/IncomingRulesTests.cs index c8ec0e6..84df39e 100644 --- a/src/core/tests/Deal.Tests.Unit/IncomingRulesTests.cs +++ b/src/core/tests/Deal.Tests.Unit/IncomingRulesTests.cs @@ -1,6 +1,8 @@ using System.Text.Json; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/IngressRateLimitInterceptorTests.cs b/src/core/tests/Deal.Tests.Unit/IngressRateLimitInterceptorTests.cs index 7536ce5..ddb0213 100644 --- a/src/core/tests/Deal.Tests.Unit/IngressRateLimitInterceptorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/IngressRateLimitInterceptorTests.cs @@ -1,9 +1,15 @@ using Deal.Api.Configuration; using Deal.Api.Middleware; using Deal.Grpc.Telegram; -using Deal.Modules.Pipeline.Application; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Grpc.Core; using Grpc.Health.V1; using Grpc.Net.Client; diff --git a/src/core/tests/Deal.Tests.Unit/IntegrationsDiTests.cs b/src/core/tests/Deal.Tests.Unit/IntegrationsDiTests.cs index fbd8ed2..04eb03f 100644 --- a/src/core/tests/Deal.Tests.Unit/IntegrationsDiTests.cs +++ b/src/core/tests/Deal.Tests.Unit/IntegrationsDiTests.cs @@ -2,11 +2,25 @@ using Deal.Contracts.Integrations; using Deal.Infrastructure; using Deal.Infrastructure.Data; using Deal.Infrastructure.Integrations; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Pipeline.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants; using Microsoft.Extensions.DependencyInjection; diff --git a/src/core/tests/Deal.Tests.Unit/InviteCodeGeneratorTests.cs b/src/core/tests/Deal.Tests.Unit/InviteCodeGeneratorTests.cs index 1ff9d0f..9e9be9d 100644 --- a/src/core/tests/Deal.Tests.Unit/InviteCodeGeneratorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/InviteCodeGeneratorTests.cs @@ -1,4 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/InviteStoreTests.cs b/src/core/tests/Deal.Tests.Unit/InviteStoreTests.cs index 0b82e96..92a0665 100644 --- a/src/core/tests/Deal.Tests.Unit/InviteStoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/InviteStoreTests.cs @@ -1,7 +1,10 @@ using Deal.Infrastructure.Persistence; using Deal.Infrastructure.Persistence.Repositories; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Microsoft.EntityFrameworkCore; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/InvitesServiceTests.cs b/src/core/tests/Deal.Tests.Unit/InvitesServiceTests.cs index 947ba51..2a63e70 100644 --- a/src/core/tests/Deal.Tests.Unit/InvitesServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/InvitesServiceTests.cs @@ -1,5 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/JoinEndpointHttpTests.cs b/src/core/tests/Deal.Tests.Unit/JoinEndpointHttpTests.cs index 86d05ed..32f8c25 100644 --- a/src/core/tests/Deal.Tests.Unit/JoinEndpointHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/JoinEndpointHttpTests.cs @@ -1,337 +1,340 @@ -using System.Net; -using System.Text; -using System.Text.Json; -using Deal.Api.Endpoints; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; -using Microsoft.AspNetCore.Builder; -using Microsoft.AspNetCore.Hosting; -using Microsoft.Extensions.DependencyInjection; - -namespace Deal.Tests.Unit; - -/// -/// HTTP-тесты POST /api/join (Task 6, Ruling 2/11): эквивалент curl-сценария активации на in-process Kestrel. -/// -/// -/// Ручка публичная (без сессии) — успех {ok:true, login}, кука НЕ ставится (далее обычный /api/auth/login); -/// все отказы — 400 {detail} с фиксированным текстом (невалидный/протухший/revoked код, чужой email, занятый -/// email, короткий пароль). Аудит invite_joined пишется при успехе (актор — новый пользователь тенанта). -/// Живая curl/psql-приёмка (провижининг схемы реальным TenantProvisioningService) — ⚠ Manual (нужен Postgres); -/// здесь провижининг заменён FakeTenantProvisioner, остальная семантика — как в проде (реальные сервисы модуля). -/// -public sealed class JoinEndpointHttpTests -{ - private const string Email = "new-user@example.com"; - private const string OtherEmail = "other@example.com"; - private const string Password = "pass1234"; - private const string Code = "abcdefghijklmnop"; - - private const string InviteNotFoundDetail = "Приглашение не найдено"; - private const string InviteExpiredDetail = "Срок действия приглашения истёк"; - private const string InviteUsedDetail = "Приглашение уже использовано"; - private const string InviteRevokedDetail = "Приглашение отозвано"; - private const string EmailMismatchDetail = "Email не совпадает с приглашением"; - private const string EmailTakenDetail = "Этот email уже зарегистрирован"; - private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)"; - - [Fact] - public async Task Join_WithValidPendingInvite_ReturnsOkCreatesUserAndWritesAudit() - { - var inviteStore = new FakeInviteStore(); - inviteStore.AddInvite(NewInvite(Email)); - var authStore = new FakeAuthStore(); - var tenantStore = new FakeTenantStore(); - var provisioner = new FakeTenantProvisioner(); - var auditStore = new FakeAuditLogStore(); - - await RunAsync( - inviteStore, tenantStore, provisioner, authStore, auditStore, - async (baseAddress, client) => - { - using HttpResponseMessage response = await PostJsonAsync( - client, $"{baseAddress}/api/join", new { code = Code, email = Email, name = "Acme", password = Password }); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - JsonElement body = await ReadJsonAsync(response); - Assert.True(body.GetProperty("ok").GetBoolean()); - Assert.Equal(Email, body.GetProperty("login").GetString()); - // План Task 6: кука НЕ ставится — после активации обычный /api/auth/login. - Assert.False(response.Headers.Contains("Set-Cookie")); - }); - - // Пользователь создан (login=email, хэш через фейк-хэшер), тенант создан и провижинен один раз, инвайт activated. - StoredUserDto user = Assert.Single(authStore.Users); - Assert.Equal(Email, user.Login); - Assert.Equal(new FakePasswordHasher().Hash(Password), user.PasswordHash); - TenantRecordDto tenant = Assert.Single(tenantStore.Tenants); - Assert.Equal("Acme", tenant.Name); - Assert.Equal(user.TenantId, tenant.Id); - Assert.Equal(new[] { $"tenant_{tenant.Id:N}" }, provisioner.ProvisionedSchemaNames); - InviteDto invite = Assert.Single(inviteStore.Invites); - Assert.Equal(InviteStatuses.Activated, invite.Status); - Assert.NotNull(invite.ActivatedAt); - - // Аудит invite_joined: актор — пользователь тенанта, детали email+codeHash (Ruling 4, Task 6; этап 10 T1). - AuditRecordDto audit = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.InviteJoined); - Assert.Equal(AuditActorTypes.Tenant, audit.ActorType); - Assert.Equal(user.Id, audit.ActorId); - Assert.Equal(user.TenantId, audit.TenantId); - AssertDetailHasEmailAndCode(audit, Email, Code); - } - - [Fact] - public async Task Join_WithSameCodeTwice_SecondReturns400AlreadyUsed() - { - var inviteStore = new FakeInviteStore(); - inviteStore.AddInvite(NewInvite(Email)); - var authStore = new FakeAuthStore(); - - await RunAsync( - inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), authStore, new FakeAuditLogStore(), - async (baseAddress, client) => - { - using (HttpResponseMessage first = await PostJsonAsync( - client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = Password })) - { - Assert.Equal(HttpStatusCode.OK, first.StatusCode); - } - - using HttpResponseMessage second = await PostJsonAsync( - client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = Password }); - - Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode); - Assert.Equal(InviteUsedDetail, (await ReadJsonAsync(second)).GetProperty("detail").GetString()); - }); - - // Побочных эффектов от повторной попытки нет (CAS: один пользователь/тенант). - Assert.Single(authStore.Users); - } - - [Fact] - public async Task Join_WithMismatchedEmail_Returns400EmailMismatchAndKeepsInvite() - { - var inviteStore = new FakeInviteStore(); - inviteStore.AddInvite(NewInvite(Email)); - var authStore = new FakeAuthStore(); - - await RunAsync( - inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), authStore, new FakeAuditLogStore(), - async (baseAddress, client) => - { - using HttpResponseMessage response = await PostJsonAsync( - client, $"{baseAddress}/api/join", new { code = Code, email = OtherEmail, password = Password }); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal(EmailMismatchDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - - Assert.Equal(InviteStatuses.Pending, Assert.Single(inviteStore.Invites).Status); - Assert.Empty(authStore.Users); - } - - [Fact] - public async Task Join_WithRevokedInvite_Returns400Revoked() - { - var inviteStore = new FakeInviteStore(); - inviteStore.AddInvite(NewInvite(Email, status: InviteStatuses.Revoked)); - - await RunAsync( - inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakeAuditLogStore(), - async (baseAddress, client) => - { - using HttpResponseMessage response = await PostJsonAsync( - client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = Password }); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal(InviteRevokedDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task Join_WithExpiredInvite_Returns400Expired() - { - var inviteStore = new FakeInviteStore(); - inviteStore.AddInvite(NewInvite(Email, expiresAt: DateTimeOffset.UtcNow.AddHours(-1))); - - await RunAsync( - inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakeAuditLogStore(), - async (baseAddress, client) => - { - using HttpResponseMessage response = await PostJsonAsync( - client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = Password }); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal(InviteExpiredDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task Join_WithUnknownCode_Returns400NotFound() - { - await RunAsync( - new FakeInviteStore(), new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakeAuditLogStore(), - async (baseAddress, client) => - { - using HttpResponseMessage response = await PostJsonAsync( - client, $"{baseAddress}/api/join", new { code = "no-such-code-1234", email = Email, password = Password }); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal(InviteNotFoundDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task Join_WithShortPassword_Returns400PasswordTooShort() - { - var inviteStore = new FakeInviteStore(); - inviteStore.AddInvite(NewInvite(Email)); - - await RunAsync( - inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakeAuditLogStore(), - async (baseAddress, client) => - { - using HttpResponseMessage response = await PostJsonAsync( - client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = "abc" }); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal(PasswordTooShortDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - - Assert.Equal(InviteStatuses.Pending, Assert.Single(inviteStore.Invites).Status); - } - - [Fact] - public async Task Join_WithTakenEmail_Returns400EmailAlreadyRegistered() - { - var inviteStore = new FakeInviteStore(); - inviteStore.AddInvite(NewInvite(Email)); - var authStore = new FakeAuthStore(); - authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: "active", "hash")); - - await RunAsync( - inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), authStore, new FakeAuditLogStore(), - async (baseAddress, client) => - { - using HttpResponseMessage response = await PostJsonAsync( - client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = Password }); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal(EmailTakenDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - - Assert.Equal(InviteStatuses.Pending, Assert.Single(inviteStore.Invites).Status); - Assert.Single(authStore.Users); - } - - [Fact] - public async Task Join_ExistingTenantInvite_JoinsTenantWithoutCreatingNewOne() - { - var tenantId = Guid.NewGuid(); - var inviteStore = new FakeInviteStore(); - inviteStore.AddInvite(NewInvite(Email, tenantId: tenantId)); - // Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему. - var tenantStore = new FakeTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow)); - var provisioner = new FakeTenantProvisioner(); - var authStore = new FakeAuthStore(); - - await RunAsync( - inviteStore, tenantStore, provisioner, authStore, new FakeAuditLogStore(), - async (baseAddress, client) => - { - using HttpResponseMessage response = await PostJsonAsync( - client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = Password }); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - }); - - Assert.Equal(new[] { tenantId }, tenantStore.Tenants.Select(t => t.Id)); - Assert.Empty(provisioner.ProvisionedSchemaNames); - Assert.Equal(tenantId, Assert.Single(authStore.Users).TenantId); - } - - // ─── Хелперы ───────────────────────────────────────────────────────── - - // Поднимает in-process Kestrel с /api/join на фейк-хранилищах и прогоняет сценарий. - private static async Task RunAsync( - FakeInviteStore inviteStore, - FakeTenantStore tenantStore, - FakeTenantProvisioner provisioner, - FakeAuthStore authStore, - FakeAuditLogStore auditStore, - Func scenario) - { - int port = TestPort.Allocate(); - WebApplicationBuilder builder = WebApplication.CreateBuilder(); - builder.WebHost.ConfigureKestrel(kestrel => kestrel.Listen(IPAddress.Loopback, port)); - - builder.Services.AddTenantsModule(); - // FakePasswordHasher регистрируется ПОСЛЕ AddTenantsModule (DefaultPasswordHasher) — побеждает - // последняя регистрация (зеркало OperatorAuthHttpHost). - builder.Services.AddSingleton(); - builder.Services.AddSingleton(authStore); - builder.Services.AddSingleton(auditStore); - builder.Services.AddSingleton(inviteStore); - builder.Services.AddSingleton(tenantStore); - builder.Services.AddSingleton(provisioner); - - WebApplication app = builder.Build(); - app.MapJoinEndpoint(); - await app.StartAsync(); - try - { - await scenario($"http://127.0.0.1:{port}", CreateClient($"http://127.0.0.1:{port}")); - } - finally - { - await app.StopAsync(); - await app.DisposeAsync(); - } - } - - // Создаёт приглашение-строку для сидирования (дефолт — живой pending). - private static InviteDto NewInvite(string email, Guid? tenantId = null, string status = InviteStatuses.Pending, DateTimeOffset? expiresAt = null) => - new( - Code, - email, - tenantId, - status, - expiresAt ?? DateTimeOffset.UtcNow.AddHours(InvitesService.ExpiryHours), - ActivatedAt: status == InviteStatuses.Activated ? DateTimeOffset.UtcNow.AddDays(-1) : null, - CreatedById: Guid.NewGuid(), - DateTimeOffset.UtcNow); - - // Проверяет DetailJson записи аудита: поля email и codeHash (Security review: код — хэш). - private static void AssertDetailHasEmailAndCode(AuditRecordDto record, string email, string code) - { - Assert.NotNull(record.DetailJson); - using var document = JsonDocument.Parse(record.DetailJson!); - JsonElement root = document.RootElement; - Assert.Equal(email, root.GetProperty("email").GetString()); - Assert.Equal(SessionTokens.HashToken(code), root.GetProperty("codeHash").GetString()); - } - - - // HTTP-клиент с собственным CookieContainer. - private static HttpClient CreateClient(string baseAddress) => - new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) - { - BaseAddress = new Uri(baseAddress), - }; - - // POST JSON-тела и возврат ответа. - private static async Task PostJsonAsync(HttpClient client, string url, object body) - { - string json = JsonSerializer.Serialize(body); - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - return await client.PostAsync(url, content); - } - - // Читает тело ответа как JSON-документ (корень, отвязанный от документа). - private static async Task ReadJsonAsync(HttpResponseMessage response) - { - await using Stream stream = await response.Content.ReadAsStreamAsync(); - using var document = await JsonDocument.ParseAsync(stream); - return document.RootElement.Clone(); - } -} +using System.Net; +using System.Text; +using System.Text.Json; +using Deal.Api.Endpoints; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; +using Microsoft.AspNetCore.Builder; +using Microsoft.AspNetCore.Hosting; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Tests.Unit; + +/// +/// HTTP-тесты POST /api/join (Task 6, Ruling 2/11): эквивалент curl-сценария активации на in-process Kestrel. +/// +/// +/// Ручка публичная (без сессии) — успех {ok:true, login}, кука НЕ ставится (далее обычный /api/auth/login); +/// все отказы — 400 {detail} с фиксированным текстом (невалидный/протухший/revoked код, чужой email, занятый +/// email, короткий пароль). Аудит invite_joined пишется при успехе (актор — новый пользователь тенанта). +/// Живая curl/psql-приёмка (провижининг схемы реальным TenantProvisioningService) — ⚠ Manual (нужен Postgres); +/// здесь провижининг заменён FakeTenantProvisioner, остальная семантика — как в проде (реальные сервисы модуля). +/// +public sealed class JoinEndpointHttpTests +{ + private const string Email = "new-user@example.com"; + private const string OtherEmail = "other@example.com"; + private const string Password = "pass1234"; + private const string Code = "abcdefghijklmnop"; + + private const string InviteNotFoundDetail = "Приглашение не найдено"; + private const string InviteExpiredDetail = "Срок действия приглашения истёк"; + private const string InviteUsedDetail = "Приглашение уже использовано"; + private const string InviteRevokedDetail = "Приглашение отозвано"; + private const string EmailMismatchDetail = "Email не совпадает с приглашением"; + private const string EmailTakenDetail = "Этот email уже зарегистрирован"; + private const string PasswordTooShortDetail = "Пароль слишком короткий (минимум 8 символов)"; + + [Fact] + public async Task Join_WithValidPendingInvite_ReturnsOkCreatesUserAndWritesAudit() + { + var inviteStore = new FakeInviteStore(); + inviteStore.AddInvite(NewInvite(Email)); + var authStore = new FakeAuthStore(); + var tenantStore = new FakeTenantStore(); + var provisioner = new FakeTenantProvisioner(); + var auditStore = new FakeAuditLogStore(); + + await RunAsync( + inviteStore, tenantStore, provisioner, authStore, auditStore, + async (baseAddress, client) => + { + using HttpResponseMessage response = await PostJsonAsync( + client, $"{baseAddress}/api/join", new { code = Code, email = Email, name = "Acme", password = Password }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + JsonElement body = await ReadJsonAsync(response); + Assert.True(body.GetProperty("ok").GetBoolean()); + Assert.Equal(Email, body.GetProperty("login").GetString()); + // План Task 6: кука НЕ ставится — после активации обычный /api/auth/login. + Assert.False(response.Headers.Contains("Set-Cookie")); + }); + + // Пользователь создан (login=email, хэш через фейк-хэшер), тенант создан и провижинен один раз, инвайт activated. + StoredUserDto user = Assert.Single(authStore.Users); + Assert.Equal(Email, user.Login); + Assert.Equal(new FakePasswordHasher().Hash(Password), user.PasswordHash); + TenantRecordDto tenant = Assert.Single(tenantStore.Tenants); + Assert.Equal("Acme", tenant.Name); + Assert.Equal(user.TenantId, tenant.Id); + Assert.Equal(new[] { $"tenant_{tenant.Id:N}" }, provisioner.ProvisionedSchemaNames); + InviteDto invite = Assert.Single(inviteStore.Invites); + Assert.Equal(InviteStatuses.Activated, invite.Status); + Assert.NotNull(invite.ActivatedAt); + + // Аудит invite_joined: актор — пользователь тенанта, детали email+codeHash (Ruling 4, Task 6; этап 10 T1). + AuditRecordDto audit = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.InviteJoined); + Assert.Equal(AuditActorTypes.Tenant, audit.ActorType); + Assert.Equal(user.Id, audit.ActorId); + Assert.Equal(user.TenantId, audit.TenantId); + AssertDetailHasEmailAndCode(audit, Email, Code); + } + + [Fact] + public async Task Join_WithSameCodeTwice_SecondReturns400AlreadyUsed() + { + var inviteStore = new FakeInviteStore(); + inviteStore.AddInvite(NewInvite(Email)); + var authStore = new FakeAuthStore(); + + await RunAsync( + inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), authStore, new FakeAuditLogStore(), + async (baseAddress, client) => + { + using (HttpResponseMessage first = await PostJsonAsync( + client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = Password })) + { + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + } + + using HttpResponseMessage second = await PostJsonAsync( + client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = Password }); + + Assert.Equal(HttpStatusCode.BadRequest, second.StatusCode); + Assert.Equal(InviteUsedDetail, (await ReadJsonAsync(second)).GetProperty("detail").GetString()); + }); + + // Побочных эффектов от повторной попытки нет (CAS: один пользователь/тенант). + Assert.Single(authStore.Users); + } + + [Fact] + public async Task Join_WithMismatchedEmail_Returns400EmailMismatchAndKeepsInvite() + { + var inviteStore = new FakeInviteStore(); + inviteStore.AddInvite(NewInvite(Email)); + var authStore = new FakeAuthStore(); + + await RunAsync( + inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), authStore, new FakeAuditLogStore(), + async (baseAddress, client) => + { + using HttpResponseMessage response = await PostJsonAsync( + client, $"{baseAddress}/api/join", new { code = Code, email = OtherEmail, password = Password }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(EmailMismatchDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + + Assert.Equal(InviteStatuses.Pending, Assert.Single(inviteStore.Invites).Status); + Assert.Empty(authStore.Users); + } + + [Fact] + public async Task Join_WithRevokedInvite_Returns400Revoked() + { + var inviteStore = new FakeInviteStore(); + inviteStore.AddInvite(NewInvite(Email, status: InviteStatuses.Revoked)); + + await RunAsync( + inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakeAuditLogStore(), + async (baseAddress, client) => + { + using HttpResponseMessage response = await PostJsonAsync( + client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = Password }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(InviteRevokedDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task Join_WithExpiredInvite_Returns400Expired() + { + var inviteStore = new FakeInviteStore(); + inviteStore.AddInvite(NewInvite(Email, expiresAt: DateTimeOffset.UtcNow.AddHours(-1))); + + await RunAsync( + inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakeAuditLogStore(), + async (baseAddress, client) => + { + using HttpResponseMessage response = await PostJsonAsync( + client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = Password }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(InviteExpiredDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task Join_WithUnknownCode_Returns400NotFound() + { + await RunAsync( + new FakeInviteStore(), new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakeAuditLogStore(), + async (baseAddress, client) => + { + using HttpResponseMessage response = await PostJsonAsync( + client, $"{baseAddress}/api/join", new { code = "no-such-code-1234", email = Email, password = Password }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(InviteNotFoundDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task Join_WithShortPassword_Returns400PasswordTooShort() + { + var inviteStore = new FakeInviteStore(); + inviteStore.AddInvite(NewInvite(Email)); + + await RunAsync( + inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), new FakeAuthStore(), new FakeAuditLogStore(), + async (baseAddress, client) => + { + using HttpResponseMessage response = await PostJsonAsync( + client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = "abc" }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(PasswordTooShortDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + + Assert.Equal(InviteStatuses.Pending, Assert.Single(inviteStore.Invites).Status); + } + + [Fact] + public async Task Join_WithTakenEmail_Returns400EmailAlreadyRegistered() + { + var inviteStore = new FakeInviteStore(); + inviteStore.AddInvite(NewInvite(Email)); + var authStore = new FakeAuthStore(); + authStore.AddUser(new StoredUserDto(Guid.NewGuid(), Email, TenantId: Guid.NewGuid(), Status: "active", "hash")); + + await RunAsync( + inviteStore, new FakeTenantStore(), new FakeTenantProvisioner(), authStore, new FakeAuditLogStore(), + async (baseAddress, client) => + { + using HttpResponseMessage response = await PostJsonAsync( + client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = Password }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(EmailTakenDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + + Assert.Equal(InviteStatuses.Pending, Assert.Single(inviteStore.Invites).Status); + Assert.Single(authStore.Users); + } + + [Fact] + public async Task Join_ExistingTenantInvite_JoinsTenantWithoutCreatingNewOne() + { + var tenantId = Guid.NewGuid(); + var inviteStore = new FakeInviteStore(); + inviteStore.AddInvite(NewInvite(Email, tenantId: tenantId)); + // Активный тенант уже существует (создан оператором ранее) — join присоединяется к нему. + var tenantStore = new FakeTenantStore(new TenantRecordDto(tenantId, "Existing", TenantStatuses.Active, DateTimeOffset.UtcNow)); + var provisioner = new FakeTenantProvisioner(); + var authStore = new FakeAuthStore(); + + await RunAsync( + inviteStore, tenantStore, provisioner, authStore, new FakeAuditLogStore(), + async (baseAddress, client) => + { + using HttpResponseMessage response = await PostJsonAsync( + client, $"{baseAddress}/api/join", new { code = Code, email = Email, password = Password }); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + }); + + Assert.Equal(new[] { tenantId }, tenantStore.Tenants.Select(t => t.Id)); + Assert.Empty(provisioner.ProvisionedSchemaNames); + Assert.Equal(tenantId, Assert.Single(authStore.Users).TenantId); + } + + // ─── Хелперы ───────────────────────────────────────────────────────── + + // Поднимает in-process Kestrel с /api/join на фейк-хранилищах и прогоняет сценарий. + private static async Task RunAsync( + FakeInviteStore inviteStore, + FakeTenantStore tenantStore, + FakeTenantProvisioner provisioner, + FakeAuthStore authStore, + FakeAuditLogStore auditStore, + Func scenario) + { + int port = TestPort.Allocate(); + WebApplicationBuilder builder = WebApplication.CreateBuilder(); + builder.WebHost.ConfigureKestrel(kestrel => kestrel.Listen(IPAddress.Loopback, port)); + + builder.Services.AddTenantsModule(); + // FakePasswordHasher регистрируется ПОСЛЕ AddTenantsModule (DefaultPasswordHasher) — побеждает + // последняя регистрация (зеркало OperatorAuthHttpHost). + builder.Services.AddSingleton(); + builder.Services.AddSingleton(authStore); + builder.Services.AddSingleton(auditStore); + builder.Services.AddSingleton(inviteStore); + builder.Services.AddSingleton(tenantStore); + builder.Services.AddSingleton(provisioner); + + WebApplication app = builder.Build(); + app.MapJoinEndpoint(); + await app.StartAsync(); + try + { + await scenario($"http://127.0.0.1:{port}", CreateClient($"http://127.0.0.1:{port}")); + } + finally + { + await app.StopAsync(); + await app.DisposeAsync(); + } + } + + // Создаёт приглашение-строку для сидирования (дефолт — живой pending). + private static InviteDto NewInvite(string email, Guid? tenantId = null, string status = InviteStatuses.Pending, DateTimeOffset? expiresAt = null) => + new( + Code, + email, + tenantId, + status, + expiresAt ?? DateTimeOffset.UtcNow.AddHours(InvitesService.ExpiryHours), + ActivatedAt: status == InviteStatuses.Activated ? DateTimeOffset.UtcNow.AddDays(-1) : null, + CreatedById: Guid.NewGuid(), + DateTimeOffset.UtcNow); + + // Проверяет DetailJson записи аудита: поля email и codeHash (Security review: код — хэш). + private static void AssertDetailHasEmailAndCode(AuditRecordDto record, string email, string code) + { + Assert.NotNull(record.DetailJson); + using var document = JsonDocument.Parse(record.DetailJson!); + JsonElement root = document.RootElement; + Assert.Equal(email, root.GetProperty("email").GetString()); + Assert.Equal(SessionTokens.HashToken(code), root.GetProperty("codeHash").GetString()); + } + + + // HTTP-клиент с собственным CookieContainer. + private static HttpClient CreateClient(string baseAddress) => + new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) + { + BaseAddress = new Uri(baseAddress), + }; + + // POST JSON-тела и возврат ответа. + private static async Task PostJsonAsync(HttpClient client, string url, object body) + { + string json = JsonSerializer.Serialize(body); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + return await client.PostAsync(url, content); + } + + // Читает тело ответа как JSON-документ (корень, отвязанный от документа). + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + await using Stream stream = await response.Content.ReadAsStreamAsync(); + using var document = await JsonDocument.ParseAsync(stream); + return document.RootElement.Clone(); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/JoinFlowTests.cs b/src/core/tests/Deal.Tests.Unit/JoinFlowTests.cs index be450cb..048d24f 100644 --- a/src/core/tests/Deal.Tests.Unit/JoinFlowTests.cs +++ b/src/core/tests/Deal.Tests.Unit/JoinFlowTests.cs @@ -1,5 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/LocalAiClassifierTests.cs b/src/core/tests/Deal.Tests.Unit/LocalAiClassifierTests.cs index f999c00..90309ab 100644 --- a/src/core/tests/Deal.Tests.Unit/LocalAiClassifierTests.cs +++ b/src/core/tests/Deal.Tests.Unit/LocalAiClassifierTests.cs @@ -2,7 +2,10 @@ using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; using Deal.Infrastructure.Integrations; using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/LocalColumnSuggesterTests.cs b/src/core/tests/Deal.Tests.Unit/LocalColumnSuggesterTests.cs index 8440fa8..5a160c7 100644 --- a/src/core/tests/Deal.Tests.Unit/LocalColumnSuggesterTests.cs +++ b/src/core/tests/Deal.Tests.Unit/LocalColumnSuggesterTests.cs @@ -2,9 +2,15 @@ using System.Globalization; using Deal.Contracts.Integrations; using Deal.Contracts.Integrations.Models; using Deal.Infrastructure.Integrations; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/LocalMlClientTests.cs b/src/core/tests/Deal.Tests.Unit/LocalMlClientTests.cs index b8bee65..0863434 100644 --- a/src/core/tests/Deal.Tests.Unit/LocalMlClientTests.cs +++ b/src/core/tests/Deal.Tests.Unit/LocalMlClientTests.cs @@ -1,8 +1,15 @@ using System.Text.Json; using Deal.Contracts.Integrations.Models; using Deal.Infrastructure.Integrations; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Settings.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/MessageParseCoreTests.cs b/src/core/tests/Deal.Tests.Unit/MessageParseCoreTests.cs index cf5caea..2f6e230 100644 --- a/src/core/tests/Deal.Tests.Unit/MessageParseCoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/MessageParseCoreTests.cs @@ -2,7 +2,10 @@ using System.Text.Json; using Deal.Modules.Kanban.Application.Models; using Deal.Modules.Pipeline.Application.Models; using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/MlOutboxFlushSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/MlOutboxFlushSchedulerTests.cs index e38b00c..f45d928 100644 --- a/src/core/tests/Deal.Tests.Unit/MlOutboxFlushSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/MlOutboxFlushSchedulerTests.cs @@ -2,10 +2,20 @@ using Deal.Api.Hosting; using Deal.Contracts.Integrations; using Deal.Infrastructure.Data; using Deal.Infrastructure.Integrations; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Settings.Application; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging.Abstractions; diff --git a/src/core/tests/Deal.Tests.Unit/MlReviewServiceTests.cs b/src/core/tests/Deal.Tests.Unit/MlReviewServiceTests.cs index c74eecc..61c5efe 100644 --- a/src/core/tests/Deal.Tests.Unit/MlReviewServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/MlReviewServiceTests.cs @@ -1,241 +1,246 @@ -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application; -using Deal.Modules.Pipeline.Application.Models; - -namespace Deal.Tests.Unit; - -/// -/// Тесты ручной проверки/разметки ML (§8): кандидаты канала/выборки и применение решения. -/// -public sealed class MlReviewServiceTests -{ - private const string Dialog = "d_1"; - - private static MlReviewService Create( - FakePipelineStore pipeline, - FakeKanjStore kanj, - FakeMlClient ml, - out CardsService cards) - { - var settings = new FakeSettingsStore(); - cards = new CardsService(kanj, settings, ml, new FakeFileStorage()); - var processing = new PipelineProcessingService(pipeline, ml, new PipelineIngestService(pipeline)); - return new MlReviewService(pipeline, kanj, cards, processing, ml); - } - - private static QueueItemDto QueueRow(long msgId, string text, string dialog = Dialog) => new() - { - Id = $"p_{msgId}", - DialogId = dialog, - MsgId = msgId, - Text = text, - Status = PipelineQueueStatuses.New, - Channel = new PipelineChannelDto("Канал", "ch", "#333"), - MsgAtMs = 1_700_000_000_000 + msgId, - }; - - private static RejectedItemDto RejectedRow(long msgId, string text, string dialog = Dialog) => new() - { - Id = $"r_{msgId}", - DialogId = dialog, - MsgId = msgId, - Text = text, - Stage = "spam_ml", - Reason = "спам", - Source = "ml", - MsgAtMs = 1_700_000_000_000 + msgId, - }; - - private static CardDto Card(long msgId, string text, string col = "inbox", string dialog = Dialog) => new() - { - Id = $"c_{msgId}", - Col = col, - Title = text, - SourceMsg = text, - SourceDialogId = dialog, - SourceMsgId = msgId, - ReceivedAtMs = 1_700_000_000_000 + msgId, - }; - - [Fact] - public async Task Candidates_MergesQueueRejectedAndCardsWithVerdicts() - { - var pipeline = new FakePipelineStore(); - pipeline.SeedQueue(QueueRow(101, "из очереди")); - pipeline.SeedRejected(RejectedRow(102, "из отсева")); - var kanj = new FakeKanjStore(); - kanj.SeedCard(Card(103, "из карточки")); - MlReviewService service = Create(pipeline, kanj, new FakeMlClient(), out _); - - IReadOnlyList items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None); - - Assert.Equal(3, items.Count); - Assert.Contains(items, item => item.Id == 101 && item.Verdict == MlReviewService.VerdictQueued && !item.Lead); - Assert.Contains(items, item => item.Id == 102 && item.Verdict == MlReviewService.VerdictRejected); - MlCandidateDto cardItem = Assert.Single(items, item => item.Id == 103); - Assert.True(cardItem.Lead); - Assert.Equal(MlReviewService.VerdictCard, cardItem.Verdict); - Assert.Equal("inbox", cardItem.Col); - } - - [Fact] - public async Task Candidates_FiltersByDialog() - { - var pipeline = new FakePipelineStore(); - pipeline.SeedQueue(QueueRow(101, "нужный", Dialog)); - pipeline.SeedQueue(QueueRow(201, "другой", "d_2")); - MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _); - - IReadOnlyList items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None); - - MlCandidateDto only = Assert.Single(items); - Assert.Equal(101, only.Id); - } - - [Fact] - public async Task Candidates_NoDialog_ReturnsAllSources() - { - var pipeline = new FakePipelineStore(); - pipeline.SeedQueue(QueueRow(101, "a", "d_1")); - pipeline.SeedQueue(QueueRow(201, "b", "d_2")); - MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _); - - IReadOnlyList items = await service.CandidatesAsync(null, 10, CancellationToken.None); - - Assert.Equal(2, items.Count); - } - - [Fact] - public async Task Candidates_ClampsLimitToMax() - { - var pipeline = new FakePipelineStore(); - for (int i = 0; i < 5; i++) - { - pipeline.SeedQueue(QueueRow(100 + i, $"текст {i}")); - } - - MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _); - - IReadOnlyList items = await service.CandidatesAsync(Dialog, 2, CancellationToken.None); - - Assert.Equal(2, items.Count); - } - - [Fact] - public async Task Apply_Skip_DoesNotLearn() - { - var pipeline = new FakePipelineStore(); - pipeline.SeedQueue(QueueRow(101, "текст")); - var ml = new FakeMlClient(); - MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _); - - MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSkip, CancellationToken.None); - - Assert.NotNull(result); - Assert.True(result!.Ok); - Assert.False(result.Learned); - Assert.Empty(ml.Pushed); - Assert.Single(pipeline.Queue); - } - - [Fact] - public async Task Apply_Spam_WithCard_TrashesAndLearns() - { - var kanj = new FakeKanjStore(); - kanj.SeedCard(Card(101, "спамный текст")); - var ml = new FakeMlClient(); - MlReviewService service = Create(new FakePipelineStore(), kanj, ml, out _); - - MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None); - - Assert.NotNull(result); - Assert.True(result!.Learned); - Assert.Equal("trash", result.Moved); - Assert.Equal("c_101", result.LeadId); - Assert.Equal("trash", Assert.Single(kanj.CardDtos).Col); - Assert.Contains(("спамный текст", "spam", 1.0), ml.Pushed); - } - - [Fact] - public async Task Apply_Spam_QueuedMessage_RejectsAndRemovesFromQueue() - { - var pipeline = new FakePipelineStore(); - pipeline.SeedQueue(QueueRow(101, "рекламный текст")); - var ml = new FakeMlClient(); - MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _); - - MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None); - - Assert.NotNull(result); - Assert.True(result!.Learned); - Assert.Null(result.Moved); - Assert.Empty(pipeline.Queue); - RejectedItemDto rejected = Assert.Single(pipeline.Rejected); - Assert.Equal("spam_ml", rejected.Stage); - Assert.Equal("ml", rejected.Source); - Assert.Contains(("рекламный текст", "spam", 1.0), ml.Pushed); - } - - [Fact] - public async Task Apply_Board_MovesCardAndLearns() - { - var kanj = new FakeKanjStore(); - kanj.SeedBoard(new ContainerDto - { - Id = "b_py", - Name = "Python", - Space = ContainerSpaces.Dashboard, - Kind = ContainerKinds.Board, - }); - kanj.SeedCard(Card(101, "python разработчик")); - var ml = new FakeMlClient(); - MlReviewService service = Create(new FakePipelineStore(), kanj, ml, out _); - - MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:b_py", CancellationToken.None); - - Assert.NotNull(result); - Assert.True(result!.Learned); - Assert.Equal("b_py", result.Moved); - Assert.Equal("b_py", Assert.Single(kanj.CardDtos).Col); - Assert.Contains(("python разработчик", "b_py", 1.0), ml.Pushed); - } - - [Fact] - public async Task Apply_UnknownBoard_ReturnsError() - { - var kanj = new FakeKanjStore(); - kanj.SeedCard(Card(101, "текст")); - MlReviewService service = Create(new FakePipelineStore(), kanj, new FakeMlClient(), out _); - - MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:missing", CancellationToken.None); - - Assert.NotNull(result); - Assert.False(result!.Ok); - Assert.Equal(MlReviewService.UnknownBoardDetail, result.Error); - } - - [Fact] - public async Task Apply_UnknownAction_ReturnsError() - { - var pipeline = new FakePipelineStore(); - pipeline.SeedQueue(QueueRow(101, "текст")); - MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _); - - MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "что-то", CancellationToken.None); - - Assert.NotNull(result); - Assert.False(result!.Ok); - Assert.Equal(MlReviewService.UnknownActionDetail, result.Error); - } - - [Fact] - public async Task Apply_MessageNotFound_ReturnsNull() - { - MlReviewService service = Create(new FakePipelineStore(), new FakeKanjStore(), new FakeMlClient(), out _); - - MlApplyResult? result = await service.ApplyAsync(Dialog, 999, MlReviewService.ActionSpam, CancellationToken.None); - - Assert.Null(result); - } -} +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; + +namespace Deal.Tests.Unit; + +/// +/// Тесты ручной проверки/разметки ML (§8): кандидаты канала/выборки и применение решения. +/// +public sealed class MlReviewServiceTests +{ + private const string Dialog = "d_1"; + + private static MlReviewService Create( + FakePipelineStore pipeline, + FakeKanjStore kanj, + FakeMlClient ml, + out CardsService cards) + { + var settings = new FakeSettingsStore(); + cards = new CardsService(kanj, settings, ml, new FakeFileStorage()); + var processing = new PipelineProcessingService(pipeline, ml, new PipelineIngestService(pipeline)); + return new MlReviewService(pipeline, kanj, cards, processing, ml); + } + + private static QueueItemDto QueueRow(long msgId, string text, string dialog = Dialog) => new() + { + Id = $"p_{msgId}", + DialogId = dialog, + MsgId = msgId, + Text = text, + Status = PipelineQueueStatuses.New, + Channel = new PipelineChannelDto("Канал", "ch", "#333"), + MsgAtMs = 1_700_000_000_000 + msgId, + }; + + private static RejectedItemDto RejectedRow(long msgId, string text, string dialog = Dialog) => new() + { + Id = $"r_{msgId}", + DialogId = dialog, + MsgId = msgId, + Text = text, + Stage = "spam_ml", + Reason = "спам", + Source = "ml", + MsgAtMs = 1_700_000_000_000 + msgId, + }; + + private static CardDto Card(long msgId, string text, string col = "inbox", string dialog = Dialog) => new() + { + Id = $"c_{msgId}", + Col = col, + Title = text, + SourceMsg = text, + SourceDialogId = dialog, + SourceMsgId = msgId, + ReceivedAtMs = 1_700_000_000_000 + msgId, + }; + + [Fact] + public async Task Candidates_MergesQueueRejectedAndCardsWithVerdicts() + { + var pipeline = new FakePipelineStore(); + pipeline.SeedQueue(QueueRow(101, "из очереди")); + pipeline.SeedRejected(RejectedRow(102, "из отсева")); + var kanj = new FakeKanjStore(); + kanj.SeedCard(Card(103, "из карточки")); + MlReviewService service = Create(pipeline, kanj, new FakeMlClient(), out _); + + IReadOnlyList items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None); + + Assert.Equal(3, items.Count); + Assert.Contains(items, item => item.Id == 101 && item.Verdict == MlReviewService.VerdictQueued && !item.Lead); + Assert.Contains(items, item => item.Id == 102 && item.Verdict == MlReviewService.VerdictRejected); + MlCandidateDto cardItem = Assert.Single(items, item => item.Id == 103); + Assert.True(cardItem.Lead); + Assert.Equal(MlReviewService.VerdictCard, cardItem.Verdict); + Assert.Equal("inbox", cardItem.Col); + } + + [Fact] + public async Task Candidates_FiltersByDialog() + { + var pipeline = new FakePipelineStore(); + pipeline.SeedQueue(QueueRow(101, "нужный", Dialog)); + pipeline.SeedQueue(QueueRow(201, "другой", "d_2")); + MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _); + + IReadOnlyList items = await service.CandidatesAsync(Dialog, 10, CancellationToken.None); + + MlCandidateDto only = Assert.Single(items); + Assert.Equal(101, only.Id); + } + + [Fact] + public async Task Candidates_NoDialog_ReturnsAllSources() + { + var pipeline = new FakePipelineStore(); + pipeline.SeedQueue(QueueRow(101, "a", "d_1")); + pipeline.SeedQueue(QueueRow(201, "b", "d_2")); + MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _); + + IReadOnlyList items = await service.CandidatesAsync(null, 10, CancellationToken.None); + + Assert.Equal(2, items.Count); + } + + [Fact] + public async Task Candidates_ClampsLimitToMax() + { + var pipeline = new FakePipelineStore(); + for (int i = 0; i < 5; i++) + { + pipeline.SeedQueue(QueueRow(100 + i, $"текст {i}")); + } + + MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _); + + IReadOnlyList items = await service.CandidatesAsync(Dialog, 2, CancellationToken.None); + + Assert.Equal(2, items.Count); + } + + [Fact] + public async Task Apply_Skip_DoesNotLearn() + { + var pipeline = new FakePipelineStore(); + pipeline.SeedQueue(QueueRow(101, "текст")); + var ml = new FakeMlClient(); + MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _); + + MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSkip, CancellationToken.None); + + Assert.NotNull(result); + Assert.True(result!.Ok); + Assert.False(result.Learned); + Assert.Empty(ml.Pushed); + Assert.Single(pipeline.Queue); + } + + [Fact] + public async Task Apply_Spam_WithCard_TrashesAndLearns() + { + var kanj = new FakeKanjStore(); + kanj.SeedCard(Card(101, "спамный текст")); + var ml = new FakeMlClient(); + MlReviewService service = Create(new FakePipelineStore(), kanj, ml, out _); + + MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None); + + Assert.NotNull(result); + Assert.True(result!.Learned); + Assert.Equal("trash", result.Moved); + Assert.Equal("c_101", result.LeadId); + Assert.Equal("trash", Assert.Single(kanj.CardDtos).Col); + Assert.Contains(("спамный текст", "spam", 1.0), ml.Pushed); + } + + [Fact] + public async Task Apply_Spam_QueuedMessage_RejectsAndRemovesFromQueue() + { + var pipeline = new FakePipelineStore(); + pipeline.SeedQueue(QueueRow(101, "рекламный текст")); + var ml = new FakeMlClient(); + MlReviewService service = Create(pipeline, new FakeKanjStore(), ml, out _); + + MlApplyResult? result = await service.ApplyAsync(Dialog, 101, MlReviewService.ActionSpam, CancellationToken.None); + + Assert.NotNull(result); + Assert.True(result!.Learned); + Assert.Null(result.Moved); + Assert.Empty(pipeline.Queue); + RejectedItemDto rejected = Assert.Single(pipeline.Rejected); + Assert.Equal("spam_ml", rejected.Stage); + Assert.Equal("ml", rejected.Source); + Assert.Contains(("рекламный текст", "spam", 1.0), ml.Pushed); + } + + [Fact] + public async Task Apply_Board_MovesCardAndLearns() + { + var kanj = new FakeKanjStore(); + kanj.SeedBoard(new ContainerDto + { + Id = "b_py", + Name = "Python", + Space = ContainerSpaces.Dashboard, + Kind = ContainerKinds.Board, + }); + kanj.SeedCard(Card(101, "python разработчик")); + var ml = new FakeMlClient(); + MlReviewService service = Create(new FakePipelineStore(), kanj, ml, out _); + + MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:b_py", CancellationToken.None); + + Assert.NotNull(result); + Assert.True(result!.Learned); + Assert.Equal("b_py", result.Moved); + Assert.Equal("b_py", Assert.Single(kanj.CardDtos).Col); + Assert.Contains(("python разработчик", "b_py", 1.0), ml.Pushed); + } + + [Fact] + public async Task Apply_UnknownBoard_ReturnsError() + { + var kanj = new FakeKanjStore(); + kanj.SeedCard(Card(101, "текст")); + MlReviewService service = Create(new FakePipelineStore(), kanj, new FakeMlClient(), out _); + + MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "board:missing", CancellationToken.None); + + Assert.NotNull(result); + Assert.False(result!.Ok); + Assert.Equal(MlReviewService.UnknownBoardDetail, result.Error); + } + + [Fact] + public async Task Apply_UnknownAction_ReturnsError() + { + var pipeline = new FakePipelineStore(); + pipeline.SeedQueue(QueueRow(101, "текст")); + MlReviewService service = Create(pipeline, new FakeKanjStore(), new FakeMlClient(), out _); + + MlApplyResult? result = await service.ApplyAsync(Dialog, 101, "что-то", CancellationToken.None); + + Assert.NotNull(result); + Assert.False(result!.Ok); + Assert.Equal(MlReviewService.UnknownActionDetail, result.Error); + } + + [Fact] + public async Task Apply_MessageNotFound_ReturnsNull() + { + MlReviewService service = Create(new FakePipelineStore(), new FakeKanjStore(), new FakeMlClient(), out _); + + MlApplyResult? result = await service.ApplyAsync(Dialog, 999, MlReviewService.ActionSpam, CancellationToken.None); + + Assert.Null(result); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/OperatorAnalyticsEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/OperatorAnalyticsEndpointsHttpTests.cs index b7a1194..1057354 100644 --- a/src/core/tests/Deal.Tests.Unit/OperatorAnalyticsEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/OperatorAnalyticsEndpointsHttpTests.cs @@ -1,340 +1,343 @@ -using System.Net; -using System.Text; -using System.Text.Json; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Tests.Unit; - -/// -/// HTTP-тесты операторской аналитики (этап 10, T3): /api/operator/analytics/{overview,tokens,activity} -/// и расширенный фильтр аудита (actorId/offset). In-process Kestrel (OperatorAuthHttpHost), фейки хранилищ. -/// -/// -/// Проверяются: 401 без операторской сессии; сводка (тенанты, токены, события, входы/выходы); агрегаты токенов -/// по провайдеру + итог и 400 на неизвестную группировку; лента действий с фильтром actorId и пагинацией -/// offset/limit; actorId/offset у GET /api/operator/audit. Живая curl/psql-приёмка — ⚠ Manual (нужен Postgres). -/// -public sealed class OperatorAnalyticsEndpointsHttpTests -{ - private const string OperatorLogin = "operator"; - private const string OperatorPassword = "operator"; - private const string UserLogin = "admin"; - private const string UserPassword = "admin"; - - private static readonly Guid ActiveTenant = Guid.NewGuid(); - private static readonly Guid SuspendedTenant = Guid.NewGuid(); - - [Fact] - public async Task Analytics_WithoutOperatorSession_Returns401ForAllHandlers() - { - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, _, _, _, _, _, _) => - { - HttpClient client = CreateClient(baseAddress); - foreach (string path in new[] { "overview", "tokens", "activity", "suspicious" }) - { - using HttpResponseMessage response = await client.GetAsync($"{baseAddress}/api/operator/analytics/{path}"); - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - JsonElement body = await ReadJsonAsync(response); - Assert.Equal("Требуется вход оператора", body.GetProperty("detail").GetString()); - } - }); - } - - [Fact] - public async Task Overview_ReturnsTenantsTokensEventsAndLoginCounters() - { - var auditStore = new FakeAuditLogStore(); - await SeedAuditAsync(auditStore, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, ActiveTenant); - await SeedAuditAsync(auditStore, AuditEvents.TenantLogout, AuditActorTypes.Tenant, ActiveTenant); - await SeedAuditAsync(auditStore, AuditEvents.TenantLoginFailed, AuditActorTypes.Tenant, ActiveTenant); - - var events = new FakeTokenUsageEventStore(); - await SeedTokensAsync(events, ActiveTenant, provider: "deepseek", prompt: 100, completion: 50, total: 150); - await SeedTokensAsync(events, ActiveTenant, provider: "openai", prompt: 40, completion: 10, total: 50); - - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, _, _, _, _, _, _) => - { - HttpClient client = CreateClient(baseAddress); - await LoginOperatorAsync(client, baseAddress); - - using HttpResponseMessage response = await client.GetAsync($"{baseAddress}/api/operator/analytics/overview"); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - JsonElement body = await ReadJsonAsync(response); - Assert.Equal(2, body.GetProperty("tenantsTotal").GetInt32()); - Assert.Equal(1, body.GetProperty("tenantsActive").GetInt32()); - Assert.Equal(200, body.GetProperty("totalTokens").GetInt64()); - Assert.Equal(140, body.GetProperty("promptTokens").GetInt64()); - Assert.Equal(60, body.GetProperty("completionTokens").GetInt64()); - Assert.Equal(2, body.GetProperty("tokenEvents").GetInt64()); - Assert.Equal(2, body.GetProperty("logins").GetInt32()); // tenant_login_ok + operator_login_ok - Assert.Equal(1, body.GetProperty("logouts").GetInt32()); - Assert.Equal(1, body.GetProperty("failedLogins").GetInt32()); - Assert.Equal(4, body.GetProperty("events").GetInt32()); // 3 семени + operator_login_ok - }, - auditStore: auditStore, - tenantStore: NewTenantStore(), - tokenUsageStore: events); - } - - [Fact] - public async Task Suspicious_ReturnsFindingsForFailedLoginBurst() - { - var auditStore = new FakeAuditLogStore(); - for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerIpThreshold; i++) - { - await auditStore.AppendAsync( - new AuditRecordDto( - AuditEvents.TenantLoginFailed, - AuditActorTypes.Tenant, - ActorId: null, - TenantId: ActiveTenant, - Ip: "10.9.0.1", - DetailJson: AuditService.ToDetailJson(new { login = "brute" }), - At: DateTimeOffset.UtcNow.AddMinutes(-i)), - CancellationToken.None); - } - - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, _, _, _, _, _, _) => - { - HttpClient client = CreateClient(baseAddress); - await LoginOperatorAsync(client, baseAddress); - - using HttpResponseMessage response = await client.GetAsync( - $"{baseAddress}/api/operator/analytics/suspicious"); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - JsonElement body = await ReadJsonAsync(response); - Assert.False(body.GetProperty("truncated").GetBoolean()); - - JsonElement items = body.GetProperty("items"); - Assert.Contains( - items.EnumerateArray(), - item => item.GetProperty("kind").GetString() == SuspiciousActivityService.KindFailedLoginsPerIp); - Assert.Contains( - items.EnumerateArray(), - item => item.GetProperty("kind").GetString() == SuspiciousActivityService.KindFailedLoginsPerLogin); - }, - auditStore: auditStore, - tenantStore: NewTenantStore()); - } - - [Fact] - public async Task Tokens_GroupsByProviderWithTotal_AndRejectsUnknownGroupBy() - { - var events = new FakeTokenUsageEventStore(); - await SeedTokensAsync(events, ActiveTenant, provider: "deepseek", prompt: 100, completion: 50, total: 150); - await SeedTokensAsync(events, SuspendedTenant, provider: "openai", prompt: 40, completion: 10, total: 50); - - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, _, _, _, _, _, _) => - { - HttpClient client = CreateClient(baseAddress); - await LoginOperatorAsync(client, baseAddress); - - using (HttpResponseMessage response = await client.GetAsync( - $"{baseAddress}/api/operator/analytics/tokens?groupBy=provider")) - { - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - JsonElement body = await ReadJsonAsync(response); - Assert.Equal("provider", body.GetProperty("groupBy").GetString()); - - JsonElement items = body.GetProperty("items"); - Assert.Equal(2, items.GetArrayLength()); - // Порядок — по убыванию total: deepseek (150) раньше openai (50). - Assert.Equal("deepseek", items[0].GetProperty("key").GetString()); - Assert.Equal(150, items[0].GetProperty("totalTokens").GetInt64()); - - JsonElement total = body.GetProperty("total"); - Assert.Equal("total", total.GetProperty("key").GetString()); - Assert.Equal(200, total.GetProperty("totalTokens").GetInt64()); - Assert.Equal(2, total.GetProperty("eventCount").GetInt64()); - } - - using (HttpResponseMessage response = await client.GetAsync( - $"{baseAddress}/api/operator/analytics/tokens?tenantId={ActiveTenant}&groupBy=provider")) - { - JsonElement body = await ReadJsonAsync(response); - Assert.Equal(150, body.GetProperty("total").GetProperty("totalTokens").GetInt64()); - } - - using (HttpResponseMessage invalid = await client.GetAsync( - $"{baseAddress}/api/operator/analytics/tokens?groupBy=week")) - { - Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode); - JsonElement body = await ReadJsonAsync(invalid); - Assert.Equal("Неизвестная группировка (day|tenant|provider|model)", body.GetProperty("detail").GetString()); - } - }, - tokenUsageStore: events); - } - - [Fact] - public async Task Activity_FiltersByActorIdAndPaginates() - { - var auditStore = new FakeAuditLogStore(); - Guid actor = Guid.NewGuid(); - await SeedAuditAsync(auditStore, AuditEvents.CardCreated, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 30); - await SeedAuditAsync(auditStore, AuditEvents.CardMoved, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 20); - await SeedAuditAsync(auditStore, AuditEvents.CardTrashed, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 10); - - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, _, _, _, _, _, _) => - { - HttpClient client = CreateClient(baseAddress); - await LoginOperatorAsync(client, baseAddress); - - using (HttpResponseMessage response = await client.GetAsync( - $"{baseAddress}/api/operator/analytics/activity?actorId={actor}&limit=2&offset=0")) - { - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - JsonElement body = await ReadJsonAsync(response); - Assert.Equal(3, body.GetProperty("total").GetInt32()); - Assert.Equal(2, body.GetProperty("limit").GetInt32()); - Assert.Equal(0, body.GetProperty("offset").GetInt32()); - JsonElement items = body.GetProperty("items"); - Assert.Equal(2, items.GetArrayLength()); - // Новые сверху: card_trashed (10 мин назад). - Assert.Equal(AuditEvents.CardTrashed, items[0].GetProperty("eventType").GetString()); - } - - using (HttpResponseMessage response = await client.GetAsync( - $"{baseAddress}/api/operator/analytics/activity?actorId={actor}&limit=2&offset=2")) - { - JsonElement body = await ReadJsonAsync(response); - Assert.Equal(3, body.GetProperty("total").GetInt32()); - Assert.Equal(1, body.GetProperty("items").GetArrayLength()); - Assert.Equal(AuditEvents.CardCreated, body.GetProperty("items")[0].GetProperty("eventType").GetString()); - } - }, - auditStore: auditStore); - } - - [Fact] - public async Task Audit_FiltersByActorIdAndOffset() - { - var auditStore = new FakeAuditLogStore(); - Guid actor = Guid.NewGuid(); - await SeedAuditAsync(auditStore, AuditEvents.CardCreated, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 30); - await SeedAuditAsync(auditStore, AuditEvents.CardMoved, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 20); - await SeedAuditAsync(auditStore, AuditEvents.CardTrashed, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 10); - await SeedAuditAsync(auditStore, AuditEvents.TenantCreated, AuditActorTypes.Operator, tenantId: null, actorId: null, minutesAgo: 5); - - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, _, _, _, _, _, _) => - { - HttpClient client = CreateClient(baseAddress); - await LoginOperatorAsync(client, baseAddress); - - using HttpResponseMessage response = await client.GetAsync( - $"{baseAddress}/api/operator/audit?actorId={actor}&offset=1&limit=10"); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - JsonElement body = await ReadJsonAsync(response); - Assert.Equal(3, body.GetProperty("total").GetInt32()); - JsonElement items = body.GetProperty("items"); - Assert.Equal(2, items.GetArrayLength()); - Assert.Equal(AuditEvents.CardMoved, items[0].GetProperty("eventType").GetString()); - }, - auditStore: auditStore); - } - - // ─── Хелперы ───────────────────────────────────────────────────────── - - private static FakeTenantStore NewTenantStore() => - new( - new TenantRecordDto(ActiveTenant, "active-tenant", TenantStatuses.Active, DateTimeOffset.UtcNow.AddDays(-2)), - new TenantRecordDto(SuspendedTenant, "suspended-tenant", TenantStatuses.Suspended, DateTimeOffset.UtcNow.AddDays(-1))); - - private static FakeOperatorAuthStore NewOperatorStore() - { - var passwordHasher = new FakePasswordHasher(); - var store = new FakeOperatorAuthStore(); - store.AddOperator(new StoredOperatorDto( - Guid.NewGuid(), OperatorLogin, "active", passwordHasher.Hash(OperatorPassword))); - return store; - } - - private static FakeAuthStore NewUserStore() - { - var passwordHasher = new FakePasswordHasher(); - var store = new FakeAuthStore(); - store.AddUser(new StoredUserDto( - Guid.NewGuid(), UserLogin, ActiveTenant, "active", passwordHasher.Hash(UserPassword))); - return store; - } - - private static Task SeedAuditAsync( - FakeAuditLogStore store, - string eventType, - string actorType, - Guid? tenantId, - Guid? actorId = null, - int minutesAgo = 1) => - store.AppendAsync( - new AuditRecordDto( - eventType, - actorType, - ActorId: actorId, - TenantId: tenantId, - Ip: null, - DetailJson: null, - At: DateTimeOffset.UtcNow.AddMinutes(-minutesAgo)), - CancellationToken.None); - - private static Task SeedTokensAsync( - FakeTokenUsageEventStore store, Guid tenantId, string provider, long prompt, long completion, long total) => - store.AppendAsync( - new TokenUsageEventDto( - TenantId: tenantId, - At: DateTimeOffset.UtcNow.AddMinutes(-5), - Provider: provider, - Model: "model-x", - Kind: TokenUsageEventKinds.Ai, - PromptTokens: prompt, - CompletionTokens: completion, - TotalTokens: total, - DetailJson: null), - CancellationToken.None); - - private static HttpClient CreateClient(string baseAddress) => - new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) - { - BaseAddress = new Uri(baseAddress), - }; - - private static async Task LoginOperatorAsync(HttpClient client, string baseAddress) - { - using HttpResponseMessage login = await PostJsonAsync( - client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword }); - Assert.Equal(HttpStatusCode.OK, login.StatusCode); - } - - private static async Task PostJsonAsync(HttpClient client, string url, object body) - { - string json = JsonSerializer.Serialize(body); - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - return await client.PostAsync(url, content); - } - - private static async Task ReadJsonAsync(HttpResponseMessage response) - { - await using Stream stream = await response.Content.ReadAsStreamAsync(); - using var document = await JsonDocument.ParseAsync(stream); - return document.RootElement.Clone(); - } -} +using System.Net; +using System.Text; +using System.Text.Json; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Tests.Unit; + +/// +/// HTTP-тесты операторской аналитики (этап 10, T3): /api/operator/analytics/{overview,tokens,activity} +/// и расширенный фильтр аудита (actorId/offset). In-process Kestrel (OperatorAuthHttpHost), фейки хранилищ. +/// +/// +/// Проверяются: 401 без операторской сессии; сводка (тенанты, токены, события, входы/выходы); агрегаты токенов +/// по провайдеру + итог и 400 на неизвестную группировку; лента действий с фильтром actorId и пагинацией +/// offset/limit; actorId/offset у GET /api/operator/audit. Живая curl/psql-приёмка — ⚠ Manual (нужен Postgres). +/// +public sealed class OperatorAnalyticsEndpointsHttpTests +{ + private const string OperatorLogin = "operator"; + private const string OperatorPassword = "operator"; + private const string UserLogin = "admin"; + private const string UserPassword = "admin"; + + private static readonly Guid ActiveTenant = Guid.NewGuid(); + private static readonly Guid SuspendedTenant = Guid.NewGuid(); + + [Fact] + public async Task Analytics_WithoutOperatorSession_Returns401ForAllHandlers() + { + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, _, _, _, _, _, _) => + { + HttpClient client = CreateClient(baseAddress); + foreach (string path in new[] { "overview", "tokens", "activity", "suspicious" }) + { + using HttpResponseMessage response = await client.GetAsync($"{baseAddress}/api/operator/analytics/{path}"); + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + JsonElement body = await ReadJsonAsync(response); + Assert.Equal("Требуется вход оператора", body.GetProperty("detail").GetString()); + } + }); + } + + [Fact] + public async Task Overview_ReturnsTenantsTokensEventsAndLoginCounters() + { + var auditStore = new FakeAuditLogStore(); + await SeedAuditAsync(auditStore, AuditEvents.TenantLoginOk, AuditActorTypes.Tenant, ActiveTenant); + await SeedAuditAsync(auditStore, AuditEvents.TenantLogout, AuditActorTypes.Tenant, ActiveTenant); + await SeedAuditAsync(auditStore, AuditEvents.TenantLoginFailed, AuditActorTypes.Tenant, ActiveTenant); + + var events = new FakeTokenUsageEventStore(); + await SeedTokensAsync(events, ActiveTenant, provider: "deepseek", prompt: 100, completion: 50, total: 150); + await SeedTokensAsync(events, ActiveTenant, provider: "openai", prompt: 40, completion: 10, total: 50); + + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, _, _, _, _, _, _) => + { + HttpClient client = CreateClient(baseAddress); + await LoginOperatorAsync(client, baseAddress); + + using HttpResponseMessage response = await client.GetAsync($"{baseAddress}/api/operator/analytics/overview"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + JsonElement body = await ReadJsonAsync(response); + Assert.Equal(2, body.GetProperty("tenantsTotal").GetInt32()); + Assert.Equal(1, body.GetProperty("tenantsActive").GetInt32()); + Assert.Equal(200, body.GetProperty("totalTokens").GetInt64()); + Assert.Equal(140, body.GetProperty("promptTokens").GetInt64()); + Assert.Equal(60, body.GetProperty("completionTokens").GetInt64()); + Assert.Equal(2, body.GetProperty("tokenEvents").GetInt64()); + Assert.Equal(2, body.GetProperty("logins").GetInt32()); // tenant_login_ok + operator_login_ok + Assert.Equal(1, body.GetProperty("logouts").GetInt32()); + Assert.Equal(1, body.GetProperty("failedLogins").GetInt32()); + Assert.Equal(4, body.GetProperty("events").GetInt32()); // 3 семени + operator_login_ok + }, + auditStore: auditStore, + tenantStore: NewTenantStore(), + tokenUsageStore: events); + } + + [Fact] + public async Task Suspicious_ReturnsFindingsForFailedLoginBurst() + { + var auditStore = new FakeAuditLogStore(); + for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerIpThreshold; i++) + { + await auditStore.AppendAsync( + new AuditRecordDto( + AuditEvents.TenantLoginFailed, + AuditActorTypes.Tenant, + ActorId: null, + TenantId: ActiveTenant, + Ip: "10.9.0.1", + DetailJson: AuditService.ToDetailJson(new { login = "brute" }), + At: DateTimeOffset.UtcNow.AddMinutes(-i)), + CancellationToken.None); + } + + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, _, _, _, _, _, _) => + { + HttpClient client = CreateClient(baseAddress); + await LoginOperatorAsync(client, baseAddress); + + using HttpResponseMessage response = await client.GetAsync( + $"{baseAddress}/api/operator/analytics/suspicious"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + JsonElement body = await ReadJsonAsync(response); + Assert.False(body.GetProperty("truncated").GetBoolean()); + + JsonElement items = body.GetProperty("items"); + Assert.Contains( + items.EnumerateArray(), + item => item.GetProperty("kind").GetString() == SuspiciousActivityService.KindFailedLoginsPerIp); + Assert.Contains( + items.EnumerateArray(), + item => item.GetProperty("kind").GetString() == SuspiciousActivityService.KindFailedLoginsPerLogin); + }, + auditStore: auditStore, + tenantStore: NewTenantStore()); + } + + [Fact] + public async Task Tokens_GroupsByProviderWithTotal_AndRejectsUnknownGroupBy() + { + var events = new FakeTokenUsageEventStore(); + await SeedTokensAsync(events, ActiveTenant, provider: "deepseek", prompt: 100, completion: 50, total: 150); + await SeedTokensAsync(events, SuspendedTenant, provider: "openai", prompt: 40, completion: 10, total: 50); + + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, _, _, _, _, _, _) => + { + HttpClient client = CreateClient(baseAddress); + await LoginOperatorAsync(client, baseAddress); + + using (HttpResponseMessage response = await client.GetAsync( + $"{baseAddress}/api/operator/analytics/tokens?groupBy=provider")) + { + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + JsonElement body = await ReadJsonAsync(response); + Assert.Equal("provider", body.GetProperty("groupBy").GetString()); + + JsonElement items = body.GetProperty("items"); + Assert.Equal(2, items.GetArrayLength()); + // Порядок — по убыванию total: deepseek (150) раньше openai (50). + Assert.Equal("deepseek", items[0].GetProperty("key").GetString()); + Assert.Equal(150, items[0].GetProperty("totalTokens").GetInt64()); + + JsonElement total = body.GetProperty("total"); + Assert.Equal("total", total.GetProperty("key").GetString()); + Assert.Equal(200, total.GetProperty("totalTokens").GetInt64()); + Assert.Equal(2, total.GetProperty("eventCount").GetInt64()); + } + + using (HttpResponseMessage response = await client.GetAsync( + $"{baseAddress}/api/operator/analytics/tokens?tenantId={ActiveTenant}&groupBy=provider")) + { + JsonElement body = await ReadJsonAsync(response); + Assert.Equal(150, body.GetProperty("total").GetProperty("totalTokens").GetInt64()); + } + + using (HttpResponseMessage invalid = await client.GetAsync( + $"{baseAddress}/api/operator/analytics/tokens?groupBy=week")) + { + Assert.Equal(HttpStatusCode.BadRequest, invalid.StatusCode); + JsonElement body = await ReadJsonAsync(invalid); + Assert.Equal("Неизвестная группировка (day|tenant|provider|model)", body.GetProperty("detail").GetString()); + } + }, + tokenUsageStore: events); + } + + [Fact] + public async Task Activity_FiltersByActorIdAndPaginates() + { + var auditStore = new FakeAuditLogStore(); + Guid actor = Guid.NewGuid(); + await SeedAuditAsync(auditStore, AuditEvents.CardCreated, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 30); + await SeedAuditAsync(auditStore, AuditEvents.CardMoved, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 20); + await SeedAuditAsync(auditStore, AuditEvents.CardTrashed, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 10); + + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, _, _, _, _, _, _) => + { + HttpClient client = CreateClient(baseAddress); + await LoginOperatorAsync(client, baseAddress); + + using (HttpResponseMessage response = await client.GetAsync( + $"{baseAddress}/api/operator/analytics/activity?actorId={actor}&limit=2&offset=0")) + { + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + JsonElement body = await ReadJsonAsync(response); + Assert.Equal(3, body.GetProperty("total").GetInt32()); + Assert.Equal(2, body.GetProperty("limit").GetInt32()); + Assert.Equal(0, body.GetProperty("offset").GetInt32()); + JsonElement items = body.GetProperty("items"); + Assert.Equal(2, items.GetArrayLength()); + // Новые сверху: card_trashed (10 мин назад). + Assert.Equal(AuditEvents.CardTrashed, items[0].GetProperty("eventType").GetString()); + } + + using (HttpResponseMessage response = await client.GetAsync( + $"{baseAddress}/api/operator/analytics/activity?actorId={actor}&limit=2&offset=2")) + { + JsonElement body = await ReadJsonAsync(response); + Assert.Equal(3, body.GetProperty("total").GetInt32()); + Assert.Equal(1, body.GetProperty("items").GetArrayLength()); + Assert.Equal(AuditEvents.CardCreated, body.GetProperty("items")[0].GetProperty("eventType").GetString()); + } + }, + auditStore: auditStore); + } + + [Fact] + public async Task Audit_FiltersByActorIdAndOffset() + { + var auditStore = new FakeAuditLogStore(); + Guid actor = Guid.NewGuid(); + await SeedAuditAsync(auditStore, AuditEvents.CardCreated, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 30); + await SeedAuditAsync(auditStore, AuditEvents.CardMoved, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 20); + await SeedAuditAsync(auditStore, AuditEvents.CardTrashed, AuditActorTypes.Tenant, ActiveTenant, actor, minutesAgo: 10); + await SeedAuditAsync(auditStore, AuditEvents.TenantCreated, AuditActorTypes.Operator, tenantId: null, actorId: null, minutesAgo: 5); + + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, _, _, _, _, _, _) => + { + HttpClient client = CreateClient(baseAddress); + await LoginOperatorAsync(client, baseAddress); + + using HttpResponseMessage response = await client.GetAsync( + $"{baseAddress}/api/operator/audit?actorId={actor}&offset=1&limit=10"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + JsonElement body = await ReadJsonAsync(response); + Assert.Equal(3, body.GetProperty("total").GetInt32()); + JsonElement items = body.GetProperty("items"); + Assert.Equal(2, items.GetArrayLength()); + Assert.Equal(AuditEvents.CardMoved, items[0].GetProperty("eventType").GetString()); + }, + auditStore: auditStore); + } + + // ─── Хелперы ───────────────────────────────────────────────────────── + + private static FakeTenantStore NewTenantStore() => + new( + new TenantRecordDto(ActiveTenant, "active-tenant", TenantStatuses.Active, DateTimeOffset.UtcNow.AddDays(-2)), + new TenantRecordDto(SuspendedTenant, "suspended-tenant", TenantStatuses.Suspended, DateTimeOffset.UtcNow.AddDays(-1))); + + private static FakeOperatorAuthStore NewOperatorStore() + { + var passwordHasher = new FakePasswordHasher(); + var store = new FakeOperatorAuthStore(); + store.AddOperator(new StoredOperatorDto( + Guid.NewGuid(), OperatorLogin, "active", passwordHasher.Hash(OperatorPassword))); + return store; + } + + private static FakeAuthStore NewUserStore() + { + var passwordHasher = new FakePasswordHasher(); + var store = new FakeAuthStore(); + store.AddUser(new StoredUserDto( + Guid.NewGuid(), UserLogin, ActiveTenant, "active", passwordHasher.Hash(UserPassword))); + return store; + } + + private static Task SeedAuditAsync( + FakeAuditLogStore store, + string eventType, + string actorType, + Guid? tenantId, + Guid? actorId = null, + int minutesAgo = 1) => + store.AppendAsync( + new AuditRecordDto( + eventType, + actorType, + ActorId: actorId, + TenantId: tenantId, + Ip: null, + DetailJson: null, + At: DateTimeOffset.UtcNow.AddMinutes(-minutesAgo)), + CancellationToken.None); + + private static Task SeedTokensAsync( + FakeTokenUsageEventStore store, Guid tenantId, string provider, long prompt, long completion, long total) => + store.AppendAsync( + new TokenUsageEventDto( + TenantId: tenantId, + At: DateTimeOffset.UtcNow.AddMinutes(-5), + Provider: provider, + Model: "model-x", + Kind: TokenUsageEventKinds.Ai, + PromptTokens: prompt, + CompletionTokens: completion, + TotalTokens: total, + DetailJson: null), + CancellationToken.None); + + private static HttpClient CreateClient(string baseAddress) => + new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) + { + BaseAddress = new Uri(baseAddress), + }; + + private static async Task LoginOperatorAsync(HttpClient client, string baseAddress) + { + using HttpResponseMessage login = await PostJsonAsync( + client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword }); + Assert.Equal(HttpStatusCode.OK, login.StatusCode); + } + + private static async Task PostJsonAsync(HttpClient client, string url, object body) + { + string json = JsonSerializer.Serialize(body); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + return await client.PostAsync(url, content); + } + + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + await using Stream stream = await response.Content.ReadAsStreamAsync(); + using var document = await JsonDocument.ParseAsync(stream); + return document.RootElement.Clone(); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/OperatorAuditEndpointsHelpersTests.cs b/src/core/tests/Deal.Tests.Unit/OperatorAuditEndpointsHelpersTests.cs index fe532de..776723f 100644 --- a/src/core/tests/Deal.Tests.Unit/OperatorAuditEndpointsHelpersTests.cs +++ b/src/core/tests/Deal.Tests.Unit/OperatorAuditEndpointsHelpersTests.cs @@ -1,5 +1,9 @@ using Deal.Api.Endpoints; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/OperatorAuditEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/OperatorAuditEndpointsHttpTests.cs index d61eff8..966fb4e 100644 --- a/src/core/tests/Deal.Tests.Unit/OperatorAuditEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/OperatorAuditEndpointsHttpTests.cs @@ -1,357 +1,360 @@ -using System.Net; -using System.Text; -using System.Text.Json; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Tests.Unit; - -/// -/// HTTP-тесты аудита (Task 4, Ruling 4): запись событий входов из login-эндпоинтов и чтение ленты -/// оператором (GET /api/operator/audit) через in-process Kestrel (OperatorAuthHttpHost) на фейк-хранилищах. -/// -/// -/// Проверяются: 401 без операторской сессии; события operator_login_ok/operator_login_failed и -/// tenant_login_ok/tenant_login_failed с полями (актор, IP, login в DetailJson, без пароля); чтение — items -/// новыми сверху + total; query-фильтры actorType/eventType. Живая curl/psql-приёмка — ⚠ Manual (нужен Postgres). -/// -public sealed class OperatorAuditEndpointsHttpTests -{ - private const string OperatorLogin = "operator"; - private const string OperatorPassword = "operator"; - private const string UserLogin = "admin"; - private const string UserPassword = "admin"; - private const string IpPrefix = "127.0.0.1"; - - [Fact] - public async Task Audit_WithoutOperatorSession_Returns401() - { - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, _, _) => - { - HttpClient client = CreateClient(baseAddress); - - using HttpResponseMessage response = await client.GetAsync($"{baseAddress}/api/operator/audit"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - JsonElement body = await ReadJsonAsync(response); - Assert.Equal("Требуется вход оператора", body.GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task OperatorLoginSuccess_WritesOperatorLoginOkAuditRecord() - { - var auditStore = new FakeAuditLogStore(); - - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, operatorStore, _) => - { - HttpClient client = CreateClient(baseAddress); - using HttpResponseMessage login = await PostJsonAsync( - client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword }); - Assert.Equal(HttpStatusCode.OK, login.StatusCode); - - AuditRecordDto record = Assert.Single(auditStore.Records); - Assert.Equal(AuditEvents.OperatorLoginOk, record.EventType); - Assert.Equal(AuditActorTypes.Operator, record.ActorType); - Assert.Equal(operatorStore.Operators.Single().Id, record.ActorId); - Assert.Null(record.TenantId); - AssertAuditIp(record); - AssertDetailLogin(record, OperatorLogin); - }, - auditStore); - } - - [Fact] - public async Task OperatorLoginFailure_WritesOperatorLoginFailedAuditRecord() - { - var auditStore = new FakeAuditLogStore(); - - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, _, _) => - { - HttpClient client = CreateClient(baseAddress); - using HttpResponseMessage login = await PostJsonAsync( - client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = "wrong-password" }); - Assert.Equal(HttpStatusCode.Unauthorized, login.StatusCode); - - AuditRecordDto record = Assert.Single(auditStore.Records); - Assert.Equal(AuditEvents.OperatorLoginFailed, record.EventType); - Assert.Equal(AuditActorTypes.Operator, record.ActorType); - // Актор неизвестен при неудаче — ActorId пуст, логин попытки — в DetailJson. - Assert.Null(record.ActorId); - AssertAuditIp(record); - AssertDetailLogin(record, OperatorLogin); - }, - auditStore); - } - - [Fact] - public async Task TenantLoginSuccessAndFailure_WriteTenantAuditRecords() - { - var auditStore = new FakeAuditLogStore(); - - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, _, userStore) => - { - HttpClient client = CreateClient(baseAddress); - - using (HttpResponseMessage ok = await PostJsonAsync( - client, $"{baseAddress}/api/auth/login", new { login = UserLogin, password = UserPassword })) - { - Assert.Equal(HttpStatusCode.OK, ok.StatusCode); - } - - using (HttpResponseMessage failed = await PostJsonAsync( - client, $"{baseAddress}/api/auth/login", new { login = UserLogin, password = "wrong-password" })) - { - Assert.Equal(HttpStatusCode.Unauthorized, failed.StatusCode); - } - - StoredUserDto user = userStore.Users.Single(u => u.Login == UserLogin); - Assert.Equal(2, auditStore.Records.Count); - - AuditRecordDto okRecord = auditStore.Records[0]; - Assert.Equal(AuditEvents.TenantLoginOk, okRecord.EventType); - Assert.Equal(AuditActorTypes.Tenant, okRecord.ActorType); - Assert.Equal(user.Id, okRecord.ActorId); - Assert.Equal(user.TenantId, okRecord.TenantId); - AssertAuditIp(okRecord); - AssertDetailLogin(okRecord, UserLogin); - - AuditRecordDto failedRecord = auditStore.Records[1]; - Assert.Equal(AuditEvents.TenantLoginFailed, failedRecord.EventType); - Assert.Equal(AuditActorTypes.Tenant, failedRecord.ActorType); - Assert.Null(failedRecord.ActorId); - Assert.Null(failedRecord.TenantId); - AssertAuditIp(failedRecord); - AssertDetailLogin(failedRecord, UserLogin); - }, - auditStore); - } - - [Fact] - public async Task Audit_ReturnsItemsNewestFirstWithTotal() - { - var auditStore = new FakeAuditLogStore(); - // Историческая запись со старым At — гарантированно последняя в выборке (проверка At DESC). - await auditStore.AppendAsync(new AuditRecordDto( - AuditEvents.TenantCreated, - AuditActorTypes.Operator, - ActorId: Guid.NewGuid(), - TenantId: null, - Ip: null, - DetailJson: """{"name":"ten"}""", - At: DateTimeOffset.UtcNow.AddMinutes(-10)), CancellationToken.None); - - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - using (HttpResponseMessage login = await PostJsonAsync( - operatorClient, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword })) - { - Assert.Equal(HttpStatusCode.OK, login.StatusCode); - } - - using HttpResponseMessage response = await operatorClient.GetAsync($"{baseAddress}/api/operator/audit"); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - - JsonElement body = await ReadJsonAsync(response); - JsonElement items = body.GetProperty("items"); - Assert.Equal(2, items.GetArrayLength()); - Assert.Equal(2, body.GetProperty("total").GetInt32()); - // Новые сверху: login-событие раньше исторического tenant_created. - Assert.Equal(AuditEvents.OperatorLoginOk, items[0].GetProperty("eventType").GetString()); - Assert.Equal(AuditEvents.TenantCreated, items[1].GetProperty("eventType").GetString()); - }, - auditStore); - } - - [Fact] - public async Task Audit_FiltersByEventTypeAndActorType() - { - var auditStore = new FakeAuditLogStore(); - - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - HttpClient tenantClient = CreateClient(baseAddress); - using (HttpResponseMessage tenantLogin = await PostJsonAsync( - tenantClient, $"{baseAddress}/api/auth/login", new { login = UserLogin, password = UserPassword })) - { - Assert.Equal(HttpStatusCode.OK, tenantLogin.StatusCode); - } - - // Фильтр actorType=tenant: только событие входа пользователя. - using (HttpResponseMessage byActor = await operatorClient.GetAsync($"{baseAddress}/api/operator/audit?actorType=tenant")) - { - JsonElement body = await ReadJsonAsync(byActor); - Assert.Equal(1, body.GetProperty("total").GetInt32()); - JsonElement items = body.GetProperty("items"); - Assert.Equal(1, items.GetArrayLength()); - Assert.Equal(AuditActorTypes.Tenant, items[0].GetProperty("actorType").GetString()); - Assert.Equal(AuditEvents.TenantLoginOk, items[0].GetProperty("eventType").GetString()); - } - - // Фильтр eventType=operator_login_ok: только событие входа оператора. - using (HttpResponseMessage byEvent = await operatorClient.GetAsync($"{baseAddress}/api/operator/audit?eventType=operator_login_ok")) - { - JsonElement body = await ReadJsonAsync(byEvent); - Assert.Equal(1, body.GetProperty("total").GetInt32()); - Assert.Equal(1, body.GetProperty("items").GetArrayLength()); - } - }, - auditStore); - } - - [Fact] - public async Task TenantLogout_WritesTenantLogoutAuditRecord() - { - var auditStore = new FakeAuditLogStore(); - - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, _, userStore) => - { - HttpClient client = CreateClient(baseAddress); - using (HttpResponseMessage login = await PostJsonAsync( - client, $"{baseAddress}/api/auth/login", new { login = UserLogin, password = UserPassword })) - { - Assert.Equal(HttpStatusCode.OK, login.StatusCode); - } - - using (HttpResponseMessage logout = await client.PostAsync($"{baseAddress}/api/auth/logout", content: null)) - { - Assert.Equal(HttpStatusCode.OK, logout.StatusCode); - } - - StoredUserDto user = userStore.Users.Single(u => u.Login == UserLogin); - AuditRecordDto record = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.TenantLogout); - Assert.Equal(AuditActorTypes.Tenant, record.ActorType); - Assert.Equal(user.Id, record.ActorId); - Assert.Equal(user.TenantId, record.TenantId); - AssertAuditIp(record); - AssertDetailLogin(record, UserLogin); - }, - auditStore); - } - - [Fact] - public async Task OperatorLogout_WritesOperatorLogoutAuditRecord() - { - var auditStore = new FakeAuditLogStore(); - - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, operatorStore, _) => - { - HttpClient client = CreateClient(baseAddress); - await LoginOperatorAsync(client, baseAddress); - - using (HttpResponseMessage logout = await client.PostAsync($"{baseAddress}/api/operator/auth/logout", content: null)) - { - Assert.Equal(HttpStatusCode.OK, logout.StatusCode); - } - - AuditRecordDto record = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.OperatorLogout); - Assert.Equal(AuditActorTypes.Operator, record.ActorType); - Assert.Equal(operatorStore.Operators.Single().Id, record.ActorId); - Assert.Null(record.TenantId); - AssertAuditIp(record); - AssertDetailLogin(record, OperatorLogin); - }, - auditStore); - } - - // ─── Хелперы ───────────────────────────────────────────────────────── - - // Фейк-хранилище оператора с активным operator/operator. - private static FakeOperatorAuthStore NewOperatorStore() - { - var passwordHasher = new FakePasswordHasher(); - var store = new FakeOperatorAuthStore(); - store.AddOperator(new StoredOperatorDto( - Guid.NewGuid(), - OperatorLogin, - "active", - passwordHasher.Hash(OperatorPassword))); - return store; - } - - // Фейк-хранилище пользователей с admin/admin в дефолтном тенанте. - private static FakeAuthStore NewUserStore() - { - var passwordHasher = new FakePasswordHasher(); - var store = new FakeAuthStore(); - store.AddUser(new StoredUserDto( - Guid.NewGuid(), - UserLogin, - TenantId: Guid.NewGuid(), - Status: "active", - PasswordHash: passwordHasher.Hash(UserPassword))); - return store; - } - - // HTTP-клиент с собственным CookieContainer (изоляция сценариев кук). - private static HttpClient CreateClient(string baseAddress) => - new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) - { - BaseAddress = new Uri(baseAddress), - }; - - // Логинит оператора (ожидается 200). - private static async Task LoginOperatorAsync(HttpClient client, string baseAddress) - { - using HttpResponseMessage login = await PostJsonAsync( - client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword }); - Assert.Equal(HttpStatusCode.OK, login.StatusCode); - } - - // POST JSON-тела и возврат ответа. - private static async Task PostJsonAsync(HttpClient client, string url, object body) - { - string json = JsonSerializer.Serialize(body); - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - return await client.PostAsync(url, content); - } - - // Читает тело ответа как JSON-документ (корень, отвязанный от документа). - private static async Task ReadJsonAsync(HttpResponseMessage response) - { - await using Stream stream = await response.Content.ReadAsStreamAsync(); - using var document = await JsonDocument.ParseAsync(stream); - return document.RootElement.Clone(); - } - - // Проверяет, что запись несёт IP клиента (loopback в тестовом хосте). - private static void AssertAuditIp(AuditRecordDto record) - { - Assert.NotNull(record.Ip); - Assert.Contains(IpPrefix, record.Ip); - } - - // Проверяет login попытки в DetailJson записи (без пароля). - private static void AssertDetailLogin(AuditRecordDto record, string login) - { - Assert.NotNull(record.DetailJson); - Assert.Contains($"\"login\":\"{login}\"", record.DetailJson); - } -} +using System.Net; +using System.Text; +using System.Text.Json; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Tests.Unit; + +/// +/// HTTP-тесты аудита (Task 4, Ruling 4): запись событий входов из login-эндпоинтов и чтение ленты +/// оператором (GET /api/operator/audit) через in-process Kestrel (OperatorAuthHttpHost) на фейк-хранилищах. +/// +/// +/// Проверяются: 401 без операторской сессии; события operator_login_ok/operator_login_failed и +/// tenant_login_ok/tenant_login_failed с полями (актор, IP, login в DetailJson, без пароля); чтение — items +/// новыми сверху + total; query-фильтры actorType/eventType. Живая curl/psql-приёмка — ⚠ Manual (нужен Postgres). +/// +public sealed class OperatorAuditEndpointsHttpTests +{ + private const string OperatorLogin = "operator"; + private const string OperatorPassword = "operator"; + private const string UserLogin = "admin"; + private const string UserPassword = "admin"; + private const string IpPrefix = "127.0.0.1"; + + [Fact] + public async Task Audit_WithoutOperatorSession_Returns401() + { + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, _, _) => + { + HttpClient client = CreateClient(baseAddress); + + using HttpResponseMessage response = await client.GetAsync($"{baseAddress}/api/operator/audit"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + JsonElement body = await ReadJsonAsync(response); + Assert.Equal("Требуется вход оператора", body.GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task OperatorLoginSuccess_WritesOperatorLoginOkAuditRecord() + { + var auditStore = new FakeAuditLogStore(); + + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, operatorStore, _) => + { + HttpClient client = CreateClient(baseAddress); + using HttpResponseMessage login = await PostJsonAsync( + client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword }); + Assert.Equal(HttpStatusCode.OK, login.StatusCode); + + AuditRecordDto record = Assert.Single(auditStore.Records); + Assert.Equal(AuditEvents.OperatorLoginOk, record.EventType); + Assert.Equal(AuditActorTypes.Operator, record.ActorType); + Assert.Equal(operatorStore.Operators.Single().Id, record.ActorId); + Assert.Null(record.TenantId); + AssertAuditIp(record); + AssertDetailLogin(record, OperatorLogin); + }, + auditStore); + } + + [Fact] + public async Task OperatorLoginFailure_WritesOperatorLoginFailedAuditRecord() + { + var auditStore = new FakeAuditLogStore(); + + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, _, _) => + { + HttpClient client = CreateClient(baseAddress); + using HttpResponseMessage login = await PostJsonAsync( + client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = "wrong-password" }); + Assert.Equal(HttpStatusCode.Unauthorized, login.StatusCode); + + AuditRecordDto record = Assert.Single(auditStore.Records); + Assert.Equal(AuditEvents.OperatorLoginFailed, record.EventType); + Assert.Equal(AuditActorTypes.Operator, record.ActorType); + // Актор неизвестен при неудаче — ActorId пуст, логин попытки — в DetailJson. + Assert.Null(record.ActorId); + AssertAuditIp(record); + AssertDetailLogin(record, OperatorLogin); + }, + auditStore); + } + + [Fact] + public async Task TenantLoginSuccessAndFailure_WriteTenantAuditRecords() + { + var auditStore = new FakeAuditLogStore(); + + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, _, userStore) => + { + HttpClient client = CreateClient(baseAddress); + + using (HttpResponseMessage ok = await PostJsonAsync( + client, $"{baseAddress}/api/auth/login", new { login = UserLogin, password = UserPassword })) + { + Assert.Equal(HttpStatusCode.OK, ok.StatusCode); + } + + using (HttpResponseMessage failed = await PostJsonAsync( + client, $"{baseAddress}/api/auth/login", new { login = UserLogin, password = "wrong-password" })) + { + Assert.Equal(HttpStatusCode.Unauthorized, failed.StatusCode); + } + + StoredUserDto user = userStore.Users.Single(u => u.Login == UserLogin); + Assert.Equal(2, auditStore.Records.Count); + + AuditRecordDto okRecord = auditStore.Records[0]; + Assert.Equal(AuditEvents.TenantLoginOk, okRecord.EventType); + Assert.Equal(AuditActorTypes.Tenant, okRecord.ActorType); + Assert.Equal(user.Id, okRecord.ActorId); + Assert.Equal(user.TenantId, okRecord.TenantId); + AssertAuditIp(okRecord); + AssertDetailLogin(okRecord, UserLogin); + + AuditRecordDto failedRecord = auditStore.Records[1]; + Assert.Equal(AuditEvents.TenantLoginFailed, failedRecord.EventType); + Assert.Equal(AuditActorTypes.Tenant, failedRecord.ActorType); + Assert.Null(failedRecord.ActorId); + Assert.Null(failedRecord.TenantId); + AssertAuditIp(failedRecord); + AssertDetailLogin(failedRecord, UserLogin); + }, + auditStore); + } + + [Fact] + public async Task Audit_ReturnsItemsNewestFirstWithTotal() + { + var auditStore = new FakeAuditLogStore(); + // Историческая запись со старым At — гарантированно последняя в выборке (проверка At DESC). + await auditStore.AppendAsync(new AuditRecordDto( + AuditEvents.TenantCreated, + AuditActorTypes.Operator, + ActorId: Guid.NewGuid(), + TenantId: null, + Ip: null, + DetailJson: """{"name":"ten"}""", + At: DateTimeOffset.UtcNow.AddMinutes(-10)), CancellationToken.None); + + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + using (HttpResponseMessage login = await PostJsonAsync( + operatorClient, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword })) + { + Assert.Equal(HttpStatusCode.OK, login.StatusCode); + } + + using HttpResponseMessage response = await operatorClient.GetAsync($"{baseAddress}/api/operator/audit"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + + JsonElement body = await ReadJsonAsync(response); + JsonElement items = body.GetProperty("items"); + Assert.Equal(2, items.GetArrayLength()); + Assert.Equal(2, body.GetProperty("total").GetInt32()); + // Новые сверху: login-событие раньше исторического tenant_created. + Assert.Equal(AuditEvents.OperatorLoginOk, items[0].GetProperty("eventType").GetString()); + Assert.Equal(AuditEvents.TenantCreated, items[1].GetProperty("eventType").GetString()); + }, + auditStore); + } + + [Fact] + public async Task Audit_FiltersByEventTypeAndActorType() + { + var auditStore = new FakeAuditLogStore(); + + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + HttpClient tenantClient = CreateClient(baseAddress); + using (HttpResponseMessage tenantLogin = await PostJsonAsync( + tenantClient, $"{baseAddress}/api/auth/login", new { login = UserLogin, password = UserPassword })) + { + Assert.Equal(HttpStatusCode.OK, tenantLogin.StatusCode); + } + + // Фильтр actorType=tenant: только событие входа пользователя. + using (HttpResponseMessage byActor = await operatorClient.GetAsync($"{baseAddress}/api/operator/audit?actorType=tenant")) + { + JsonElement body = await ReadJsonAsync(byActor); + Assert.Equal(1, body.GetProperty("total").GetInt32()); + JsonElement items = body.GetProperty("items"); + Assert.Equal(1, items.GetArrayLength()); + Assert.Equal(AuditActorTypes.Tenant, items[0].GetProperty("actorType").GetString()); + Assert.Equal(AuditEvents.TenantLoginOk, items[0].GetProperty("eventType").GetString()); + } + + // Фильтр eventType=operator_login_ok: только событие входа оператора. + using (HttpResponseMessage byEvent = await operatorClient.GetAsync($"{baseAddress}/api/operator/audit?eventType=operator_login_ok")) + { + JsonElement body = await ReadJsonAsync(byEvent); + Assert.Equal(1, body.GetProperty("total").GetInt32()); + Assert.Equal(1, body.GetProperty("items").GetArrayLength()); + } + }, + auditStore); + } + + [Fact] + public async Task TenantLogout_WritesTenantLogoutAuditRecord() + { + var auditStore = new FakeAuditLogStore(); + + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, _, userStore) => + { + HttpClient client = CreateClient(baseAddress); + using (HttpResponseMessage login = await PostJsonAsync( + client, $"{baseAddress}/api/auth/login", new { login = UserLogin, password = UserPassword })) + { + Assert.Equal(HttpStatusCode.OK, login.StatusCode); + } + + using (HttpResponseMessage logout = await client.PostAsync($"{baseAddress}/api/auth/logout", content: null)) + { + Assert.Equal(HttpStatusCode.OK, logout.StatusCode); + } + + StoredUserDto user = userStore.Users.Single(u => u.Login == UserLogin); + AuditRecordDto record = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.TenantLogout); + Assert.Equal(AuditActorTypes.Tenant, record.ActorType); + Assert.Equal(user.Id, record.ActorId); + Assert.Equal(user.TenantId, record.TenantId); + AssertAuditIp(record); + AssertDetailLogin(record, UserLogin); + }, + auditStore); + } + + [Fact] + public async Task OperatorLogout_WritesOperatorLogoutAuditRecord() + { + var auditStore = new FakeAuditLogStore(); + + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, operatorStore, _) => + { + HttpClient client = CreateClient(baseAddress); + await LoginOperatorAsync(client, baseAddress); + + using (HttpResponseMessage logout = await client.PostAsync($"{baseAddress}/api/operator/auth/logout", content: null)) + { + Assert.Equal(HttpStatusCode.OK, logout.StatusCode); + } + + AuditRecordDto record = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.OperatorLogout); + Assert.Equal(AuditActorTypes.Operator, record.ActorType); + Assert.Equal(operatorStore.Operators.Single().Id, record.ActorId); + Assert.Null(record.TenantId); + AssertAuditIp(record); + AssertDetailLogin(record, OperatorLogin); + }, + auditStore); + } + + // ─── Хелперы ───────────────────────────────────────────────────────── + + // Фейк-хранилище оператора с активным operator/operator. + private static FakeOperatorAuthStore NewOperatorStore() + { + var passwordHasher = new FakePasswordHasher(); + var store = new FakeOperatorAuthStore(); + store.AddOperator(new StoredOperatorDto( + Guid.NewGuid(), + OperatorLogin, + "active", + passwordHasher.Hash(OperatorPassword))); + return store; + } + + // Фейк-хранилище пользователей с admin/admin в дефолтном тенанте. + private static FakeAuthStore NewUserStore() + { + var passwordHasher = new FakePasswordHasher(); + var store = new FakeAuthStore(); + store.AddUser(new StoredUserDto( + Guid.NewGuid(), + UserLogin, + TenantId: Guid.NewGuid(), + Status: "active", + PasswordHash: passwordHasher.Hash(UserPassword))); + return store; + } + + // HTTP-клиент с собственным CookieContainer (изоляция сценариев кук). + private static HttpClient CreateClient(string baseAddress) => + new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) + { + BaseAddress = new Uri(baseAddress), + }; + + // Логинит оператора (ожидается 200). + private static async Task LoginOperatorAsync(HttpClient client, string baseAddress) + { + using HttpResponseMessage login = await PostJsonAsync( + client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword }); + Assert.Equal(HttpStatusCode.OK, login.StatusCode); + } + + // POST JSON-тела и возврат ответа. + private static async Task PostJsonAsync(HttpClient client, string url, object body) + { + string json = JsonSerializer.Serialize(body); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + return await client.PostAsync(url, content); + } + + // Читает тело ответа как JSON-документ (корень, отвязанный от документа). + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + await using Stream stream = await response.Content.ReadAsStreamAsync(); + using var document = await JsonDocument.ParseAsync(stream); + return document.RootElement.Clone(); + } + + // Проверяет, что запись несёт IP клиента (loopback в тестовом хосте). + private static void AssertAuditIp(AuditRecordDto record) + { + Assert.NotNull(record.Ip); + Assert.Contains(IpPrefix, record.Ip); + } + + // Проверяет login попытки в DetailJson записи (без пароля). + private static void AssertDetailLogin(AuditRecordDto record, string login) + { + Assert.NotNull(record.DetailJson); + Assert.Contains($"\"login\":\"{login}\"", record.DetailJson); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/OperatorAuthEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/OperatorAuthEndpointsHttpTests.cs index b9c27f4..3b5b24c 100644 --- a/src/core/tests/Deal.Tests.Unit/OperatorAuthEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/OperatorAuthEndpointsHttpTests.cs @@ -1,248 +1,251 @@ -using System.Net; -using System.Text; -using System.Text.Json; -using Deal.Api.Http; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Tests.Unit; - -/// -/// HTTP-тесты операторского auth-контура (Task 3): OperatorSessionMiddleware + OperatorAuthEndpoints -/// на фейк-хранилищах через in-process Kestrel (OperatorAuthHttpHost). -/// -/// -/// Проверяются login (кука deal_operator_session, 12 ч/httpOnly/SameSite=Lax), logout, me (401 без сессии), -/// гейт по статусу оператора и изоляция операторской/тенантной кук (Ruling 1). Тексты ошибок — 401 -/// {"detail":"…"} как в AuthEndpoints. Живая curl-приёмка на :5080 — ⚠ Manual (нужен Postgres). -/// -public sealed class OperatorAuthEndpointsHttpTests -{ - private const string OperatorLogin = "operator"; - private const string OperatorPassword = "operator"; - private const string UserLogin = "admin"; - private const string UserPassword = "admin"; - private const string OperatorCookieName = "deal_operator_session"; - - [Fact] - public async Task Login_WithValidOperatorCredentials_SetsOperatorCookieAndReturnsOk() - { - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, operatorStore, _) => - { - HttpClient client = CreateClient(baseAddress); - - using HttpResponseMessage response = await PostJsonAsync( - client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword }); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - JsonElement body = await ReadJsonAsync(response); - Assert.True(body.GetProperty("ok").GetBoolean()); - Assert.Equal(OperatorLogin, body.GetProperty("login").GetString()); - - // Кука выставлена с атрибутами Ruling 1: 12 ч (max-age=43200), httpOnly, SameSite=Lax. - string? setCookie = response.Headers.TryGetValues("Set-Cookie", out var values) - ? values.SingleOrDefault(v => v.StartsWith(OperatorCookieName, StringComparison.OrdinalIgnoreCase)) - : null; - Assert.NotNull(setCookie); - string rawCookie = setCookie!.ToLowerInvariant(); - Assert.Contains("httponly", rawCookie); - Assert.Contains("samesite=lax", rawCookie); - Assert.Contains("path=/", rawCookie); - Assert.Contains("max-age=43200", rawCookie); - }); - } - - [Fact] - public async Task Login_WithWrongOperatorPassword_Returns401() - { - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, _, _) => - { - HttpClient client = CreateClient(baseAddress); - - using HttpResponseMessage response = await PostJsonAsync( - client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = "wrong-password" }); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - Assert.Equal("Неверный логин или пароль оператора", (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task Me_WithoutOperatorSession_Returns401() - { - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, _, _) => - { - HttpClient client = CreateClient(baseAddress); - - using HttpResponseMessage response = await client.GetAsync($"{baseAddress}/api/operator/auth/me"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - Assert.Equal(AuthHelpers.OperatorUnauthorizedDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task LoginThenMe_ReturnsOperatorLogin() - { - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, _, _) => - { - HttpClient client = CreateClient(baseAddress); - await LoginOperatorAsync(client, baseAddress); - - using HttpResponseMessage response = await client.GetAsync($"{baseAddress}/api/operator/auth/me"); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - JsonElement body = await ReadJsonAsync(response); - Assert.Equal(OperatorLogin, body.GetProperty("login").GetString()); - Assert.True(body.GetProperty("ok").GetBoolean()); - }); - } - - [Fact] - public async Task LoginThenLogoutThenMe_Returns401AndRemovesCookie() - { - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, _, _) => - { - HttpClient client = CreateClient(baseAddress); - await LoginOperatorAsync(client, baseAddress); - - using HttpResponseMessage logout = await client.PostAsync( - $"{baseAddress}/api/operator/auth/logout", content: null); - Assert.Equal(HttpStatusCode.OK, logout.StatusCode); - Assert.True((await ReadJsonAsync(logout)).GetProperty("ok").GetBoolean()); - - using HttpResponseMessage me = await client.GetAsync($"{baseAddress}/api/operator/auth/me"); - Assert.Equal(HttpStatusCode.Unauthorized, me.StatusCode); - // Кука удалена: следующий logout остаётся no-op-ok (зеркало AuthEndpoints). - using HttpResponseMessage logoutAgain = await client.PostAsync( - $"{baseAddress}/api/operator/auth/logout", content: null); - Assert.Equal(HttpStatusCode.OK, logoutAgain.StatusCode); - }); - } - - [Fact] - public async Task OperatorWithNonActiveStatus_LoginSucceeds_ButMeReturns401() - { - // Ревью Task 2: ResolveSession проверяет Status оператора — неактивный не получает сессию. - FakeOperatorAuthStore operatorStore = NewOperatorStore(active: false); - - await OperatorAuthHttpHost.RunAsync( - operatorStore, - NewUserStore(), - async (baseAddress, _, _) => - { - HttpClient client = CreateClient(baseAddress); - using HttpResponseMessage login = await PostJsonAsync( - client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword }); - Assert.Equal(HttpStatusCode.OK, login.StatusCode); - - using HttpResponseMessage me = await client.GetAsync($"{baseAddress}/api/operator/auth/me"); - Assert.Equal(HttpStatusCode.Unauthorized, me.StatusCode); - Assert.Equal(AuthHelpers.OperatorUnauthorizedDetail, (await ReadJsonAsync(me)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task TenantAndOperatorCookies_DoNotResolveAcrossAuthGroups() - { - // Изоляция сессий (Ruling 1): deal_session не проходит на /api/operator/auth/me и наоборот. - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, _, _) => - { - // Операторская кука не даёт тенантную /api/auth/me (нет CurrentUser — SessionMiddleware). - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - using HttpResponseMessage tenantMe = await operatorClient.GetAsync($"{baseAddress}/api/auth/me"); - Assert.Equal(HttpStatusCode.Unauthorized, tenantMe.StatusCode); - Assert.Equal(AuthHelpers.UnauthorizedDetail, (await ReadJsonAsync(tenantMe)).GetProperty("detail").GetString()); - - // Тенантная кука не даёт операторскую /api/operator/auth/me (нет CurrentOperator). - HttpClient userClient = CreateClient(baseAddress); - using (HttpResponseMessage login = await PostJsonAsync( - userClient, $"{baseAddress}/api/auth/login", new { login = UserLogin, password = UserPassword })) - { - Assert.Equal(HttpStatusCode.OK, login.StatusCode); - } - - using HttpResponseMessage operatorMe = await userClient.GetAsync($"{baseAddress}/api/operator/auth/me"); - Assert.Equal(HttpStatusCode.Unauthorized, operatorMe.StatusCode); - Assert.Equal(AuthHelpers.OperatorUnauthorizedDetail, (await ReadJsonAsync(operatorMe)).GetProperty("detail").GetString()); - }); - } - - // ─── Хелперы ───────────────────────────────────────────────────────── - - // Логинит оператора (ожидается 200) и возвращает ответ. - private static async Task LoginOperatorAsync(HttpClient client, string baseAddress) - { - using HttpResponseMessage login = await PostJsonAsync( - client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword }); - Assert.Equal(HttpStatusCode.OK, login.StatusCode); - } - - // Фейк-хранилище оператора с активным (или нет) оператором operator/operator. - private static FakeOperatorAuthStore NewOperatorStore(bool active = true) - { - var passwordHasher = new FakePasswordHasher(); - var store = new FakeOperatorAuthStore(); - store.AddOperator(new StoredOperatorDto( - Guid.NewGuid(), - OperatorLogin, - active ? "active" : "suspended", - passwordHasher.Hash(OperatorPassword))); - return store; - } - - // Фейк-хранилище пользователей с admin/admin в дефолтном тенанте. - private static FakeAuthStore NewUserStore() - { - var passwordHasher = new FakePasswordHasher(); - var store = new FakeAuthStore(); - store.AddUser(new StoredUserDto( - Guid.NewGuid(), - UserLogin, - TenantId: Guid.NewGuid(), - Status: "active", - PasswordHash: passwordHasher.Hash(UserPassword))); - return store; - } - - // HTTP-клиент с собственным CookieContainer (изоляция сценариев кук). - private static HttpClient CreateClient(string baseAddress) => - new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) - { - BaseAddress = new Uri(baseAddress), - }; - - // POST JSON-тела и возврат ответа. - private static async Task PostJsonAsync(HttpClient client, string url, object body) - { - string json = JsonSerializer.Serialize(body); - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - return await client.PostAsync(url, content); - } - - // Читает тело ответа как JSON-документ (корень, отвязанный от документа). - private static async Task ReadJsonAsync(HttpResponseMessage response) - { - await using Stream stream = await response.Content.ReadAsStreamAsync(); - using var document = await JsonDocument.ParseAsync(stream); - return document.RootElement.Clone(); - } -} +using System.Net; +using System.Text; +using System.Text.Json; +using Deal.Api.Http; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Tests.Unit; + +/// +/// HTTP-тесты операторского auth-контура (Task 3): OperatorSessionMiddleware + OperatorAuthEndpoints +/// на фейк-хранилищах через in-process Kestrel (OperatorAuthHttpHost). +/// +/// +/// Проверяются login (кука deal_operator_session, 12 ч/httpOnly/SameSite=Lax), logout, me (401 без сессии), +/// гейт по статусу оператора и изоляция операторской/тенантной кук (Ruling 1). Тексты ошибок — 401 +/// {"detail":"…"} как в AuthEndpoints. Живая curl-приёмка на :5080 — ⚠ Manual (нужен Postgres). +/// +public sealed class OperatorAuthEndpointsHttpTests +{ + private const string OperatorLogin = "operator"; + private const string OperatorPassword = "operator"; + private const string UserLogin = "admin"; + private const string UserPassword = "admin"; + private const string OperatorCookieName = "deal_operator_session"; + + [Fact] + public async Task Login_WithValidOperatorCredentials_SetsOperatorCookieAndReturnsOk() + { + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, operatorStore, _) => + { + HttpClient client = CreateClient(baseAddress); + + using HttpResponseMessage response = await PostJsonAsync( + client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword }); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + JsonElement body = await ReadJsonAsync(response); + Assert.True(body.GetProperty("ok").GetBoolean()); + Assert.Equal(OperatorLogin, body.GetProperty("login").GetString()); + + // Кука выставлена с атрибутами Ruling 1: 12 ч (max-age=43200), httpOnly, SameSite=Lax. + string? setCookie = response.Headers.TryGetValues("Set-Cookie", out var values) + ? values.SingleOrDefault(v => v.StartsWith(OperatorCookieName, StringComparison.OrdinalIgnoreCase)) + : null; + Assert.NotNull(setCookie); + string rawCookie = setCookie!.ToLowerInvariant(); + Assert.Contains("httponly", rawCookie); + Assert.Contains("samesite=lax", rawCookie); + Assert.Contains("path=/", rawCookie); + Assert.Contains("max-age=43200", rawCookie); + }); + } + + [Fact] + public async Task Login_WithWrongOperatorPassword_Returns401() + { + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, _, _) => + { + HttpClient client = CreateClient(baseAddress); + + using HttpResponseMessage response = await PostJsonAsync( + client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = "wrong-password" }); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal("Неверный логин или пароль оператора", (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task Me_WithoutOperatorSession_Returns401() + { + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, _, _) => + { + HttpClient client = CreateClient(baseAddress); + + using HttpResponseMessage response = await client.GetAsync($"{baseAddress}/api/operator/auth/me"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(AuthHelpers.OperatorUnauthorizedDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task LoginThenMe_ReturnsOperatorLogin() + { + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, _, _) => + { + HttpClient client = CreateClient(baseAddress); + await LoginOperatorAsync(client, baseAddress); + + using HttpResponseMessage response = await client.GetAsync($"{baseAddress}/api/operator/auth/me"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + JsonElement body = await ReadJsonAsync(response); + Assert.Equal(OperatorLogin, body.GetProperty("login").GetString()); + Assert.True(body.GetProperty("ok").GetBoolean()); + }); + } + + [Fact] + public async Task LoginThenLogoutThenMe_Returns401AndRemovesCookie() + { + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, _, _) => + { + HttpClient client = CreateClient(baseAddress); + await LoginOperatorAsync(client, baseAddress); + + using HttpResponseMessage logout = await client.PostAsync( + $"{baseAddress}/api/operator/auth/logout", content: null); + Assert.Equal(HttpStatusCode.OK, logout.StatusCode); + Assert.True((await ReadJsonAsync(logout)).GetProperty("ok").GetBoolean()); + + using HttpResponseMessage me = await client.GetAsync($"{baseAddress}/api/operator/auth/me"); + Assert.Equal(HttpStatusCode.Unauthorized, me.StatusCode); + // Кука удалена: следующий logout остаётся no-op-ok (зеркало AuthEndpoints). + using HttpResponseMessage logoutAgain = await client.PostAsync( + $"{baseAddress}/api/operator/auth/logout", content: null); + Assert.Equal(HttpStatusCode.OK, logoutAgain.StatusCode); + }); + } + + [Fact] + public async Task OperatorWithNonActiveStatus_LoginSucceeds_ButMeReturns401() + { + // Ревью Task 2: ResolveSession проверяет Status оператора — неактивный не получает сессию. + FakeOperatorAuthStore operatorStore = NewOperatorStore(active: false); + + await OperatorAuthHttpHost.RunAsync( + operatorStore, + NewUserStore(), + async (baseAddress, _, _) => + { + HttpClient client = CreateClient(baseAddress); + using HttpResponseMessage login = await PostJsonAsync( + client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword }); + Assert.Equal(HttpStatusCode.OK, login.StatusCode); + + using HttpResponseMessage me = await client.GetAsync($"{baseAddress}/api/operator/auth/me"); + Assert.Equal(HttpStatusCode.Unauthorized, me.StatusCode); + Assert.Equal(AuthHelpers.OperatorUnauthorizedDetail, (await ReadJsonAsync(me)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task TenantAndOperatorCookies_DoNotResolveAcrossAuthGroups() + { + // Изоляция сессий (Ruling 1): deal_session не проходит на /api/operator/auth/me и наоборот. + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, _, _) => + { + // Операторская кука не даёт тенантную /api/auth/me (нет CurrentUser — SessionMiddleware). + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + using HttpResponseMessage tenantMe = await operatorClient.GetAsync($"{baseAddress}/api/auth/me"); + Assert.Equal(HttpStatusCode.Unauthorized, tenantMe.StatusCode); + Assert.Equal(AuthHelpers.UnauthorizedDetail, (await ReadJsonAsync(tenantMe)).GetProperty("detail").GetString()); + + // Тенантная кука не даёт операторскую /api/operator/auth/me (нет CurrentOperator). + HttpClient userClient = CreateClient(baseAddress); + using (HttpResponseMessage login = await PostJsonAsync( + userClient, $"{baseAddress}/api/auth/login", new { login = UserLogin, password = UserPassword })) + { + Assert.Equal(HttpStatusCode.OK, login.StatusCode); + } + + using HttpResponseMessage operatorMe = await userClient.GetAsync($"{baseAddress}/api/operator/auth/me"); + Assert.Equal(HttpStatusCode.Unauthorized, operatorMe.StatusCode); + Assert.Equal(AuthHelpers.OperatorUnauthorizedDetail, (await ReadJsonAsync(operatorMe)).GetProperty("detail").GetString()); + }); + } + + // ─── Хелперы ───────────────────────────────────────────────────────── + + // Логинит оператора (ожидается 200) и возвращает ответ. + private static async Task LoginOperatorAsync(HttpClient client, string baseAddress) + { + using HttpResponseMessage login = await PostJsonAsync( + client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword }); + Assert.Equal(HttpStatusCode.OK, login.StatusCode); + } + + // Фейк-хранилище оператора с активным (или нет) оператором operator/operator. + private static FakeOperatorAuthStore NewOperatorStore(bool active = true) + { + var passwordHasher = new FakePasswordHasher(); + var store = new FakeOperatorAuthStore(); + store.AddOperator(new StoredOperatorDto( + Guid.NewGuid(), + OperatorLogin, + active ? "active" : "suspended", + passwordHasher.Hash(OperatorPassword))); + return store; + } + + // Фейк-хранилище пользователей с admin/admin в дефолтном тенанте. + private static FakeAuthStore NewUserStore() + { + var passwordHasher = new FakePasswordHasher(); + var store = new FakeAuthStore(); + store.AddUser(new StoredUserDto( + Guid.NewGuid(), + UserLogin, + TenantId: Guid.NewGuid(), + Status: "active", + PasswordHash: passwordHasher.Hash(UserPassword))); + return store; + } + + // HTTP-клиент с собственным CookieContainer (изоляция сценариев кук). + private static HttpClient CreateClient(string baseAddress) => + new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) + { + BaseAddress = new Uri(baseAddress), + }; + + // POST JSON-тела и возврат ответа. + private static async Task PostJsonAsync(HttpClient client, string url, object body) + { + string json = JsonSerializer.Serialize(body); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + return await client.PostAsync(url, content); + } + + // Читает тело ответа как JSON-документ (корень, отвязанный от документа). + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + await using Stream stream = await response.Content.ReadAsStreamAsync(); + using var document = await JsonDocument.ParseAsync(stream); + return document.RootElement.Clone(); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/OperatorAuthHttpHost.cs b/src/core/tests/Deal.Tests.Unit/OperatorAuthHttpHost.cs index a22d0f0..e2fd011 100644 --- a/src/core/tests/Deal.Tests.Unit/OperatorAuthHttpHost.cs +++ b/src/core/tests/Deal.Tests.Unit/OperatorAuthHttpHost.cs @@ -8,8 +8,15 @@ using Deal.Infrastructure.Data; using Deal.Infrastructure.Integrations; using Deal.Infrastructure.Persistence; using Deal.Infrastructure.Tenancy; -using Deal.Modules.Settings.Application; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; diff --git a/src/core/tests/Deal.Tests.Unit/OperatorAuthServiceTests.cs b/src/core/tests/Deal.Tests.Unit/OperatorAuthServiceTests.cs index 4f31e20..9b2dc00 100644 --- a/src/core/tests/Deal.Tests.Unit/OperatorAuthServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/OperatorAuthServiceTests.cs @@ -1,5 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/OperatorBootstrapHostedServiceTests.cs b/src/core/tests/Deal.Tests.Unit/OperatorBootstrapHostedServiceTests.cs index 22db712..2652794 100644 --- a/src/core/tests/Deal.Tests.Unit/OperatorBootstrapHostedServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/OperatorBootstrapHostedServiceTests.cs @@ -1,5 +1,9 @@ using Deal.Api.Hosting; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; diff --git a/src/core/tests/Deal.Tests.Unit/OperatorBootstrapServiceTests.cs b/src/core/tests/Deal.Tests.Unit/OperatorBootstrapServiceTests.cs index f210bab..76d0d63 100644 --- a/src/core/tests/Deal.Tests.Unit/OperatorBootstrapServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/OperatorBootstrapServiceTests.cs @@ -1,5 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/OperatorCookieOptionsTests.cs b/src/core/tests/Deal.Tests.Unit/OperatorCookieOptionsTests.cs index a79ec1b..ce7c564 100644 --- a/src/core/tests/Deal.Tests.Unit/OperatorCookieOptionsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/OperatorCookieOptionsTests.cs @@ -1,5 +1,9 @@ using Deal.Api.Configuration; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/OperatorHealthEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/OperatorHealthEndpointsHttpTests.cs index b183bbf..54a6d5e 100644 --- a/src/core/tests/Deal.Tests.Unit/OperatorHealthEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/OperatorHealthEndpointsHttpTests.cs @@ -1,142 +1,145 @@ -using System.Net; -using System.Text; -using System.Text.Json; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Tests.Unit; - -/// -/// HTTP-тесты операторского health: GET /api/operator/health (план Task 10, Ruling 3/6/9/11). -/// -/// -/// Прогон на in-process Kestrel (OperatorAuthHttpHost) в dev-конфигурации по умолчанию (UseLocal=true для -/// ml/ai/telegram — Local-режим). Проверяются форма ответа {ok, core:{db}, services:[…]}, пометка сервисов -/// local (reachable=false) и 401 без операторской сессии. Проверка БД (SELECT 1 к dev-Postgres :5433) в тесте -/// ходит на реальный порт: при выключенном docker core.db=down (ручка жива, 200); при поднятом deal-postgres — -/// ok. Реальный gRPC-health сервисов (UseLocal=false, поднятый стек) — ⚠ Manual, как в задачах 6–9. -/// -public sealed class OperatorHealthEndpointsHttpTests -{ - private const string OperatorLogin = "operator"; - private const string OperatorPassword = "operator"; - - [Fact] - public async Task Health_WithoutOperatorSession_Returns401() - { - await RunAsync( - async (baseAddress, _, _, _, _, _, _) => - { - using HttpResponseMessage response = await CreateClient(baseAddress).GetAsync($"{baseAddress}/api/operator/health"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - Assert.Equal("Требуется вход оператора", (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task Health_InLocalMode_MarksServicesAsLocal_AndReturns200() - { - await RunAsync( - async (baseAddress, _, _, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage response = await operatorClient.GetAsync($"{baseAddress}/api/operator/health"); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - JsonElement body = await ReadJsonAsync(response); - - // ok — сводный признак (БД может быть down без поднятого Postgres — сам факт 200 проверяется выше). - Assert.True(body.TryGetProperty("ok", out _)); - - // core.db — статус БД в допуске {ok, down}; ручка не падает при недоступном Postgres. - string databaseStatus = body.GetProperty("core").GetProperty("db").GetString()!; - Assert.True(databaseStatus is "ok" or "down", $"Неожиданный статус БД: {databaseStatus}"); - - // Сервисы в dev-Local-режиме помечены local (reachable=false): форма {name, mode, status, reachable}. - JsonElement services = body.GetProperty("services"); - Assert.Equal(3, services.GetArrayLength()); - AssertServiceLocal(FindService(services, "ml")); - AssertServiceLocal(FindService(services, "ai")); - AssertServiceLocal(FindService(services, "telegram")); - - // Глубины очередей и активные сессии (§10.2): числовые поля присутствуют всегда (0 при пустой БД). - JsonElement queues = body.GetProperty("queues"); - Assert.True(queues.GetProperty("pipeline").TryGetInt64(out _)); - Assert.True(queues.GetProperty("mlOutbox").TryGetInt64(out _)); - Assert.True(body.GetProperty("sessions").GetProperty("active").TryGetInt32(out _)); - }); - } - - // Форма записи сервиса в Local-режиме (план Task 10: {reachable:false, mode:"local"} + status/local). - private static void AssertServiceLocal(JsonElement service) - { - Assert.Equal("local", service.GetProperty("mode").GetString()); - Assert.Equal("local", service.GetProperty("status").GetString()); - Assert.False(service.GetProperty("reachable").GetBoolean()); - } - - // Запись services по имени сервиса (ml/ai/telegram). - private static JsonElement FindService(JsonElement services, string name) - { - foreach (JsonElement service in services.EnumerateArray()) - { - if (service.GetProperty("name").GetString() == name) - { - return service; - } - } - - throw new Xunit.Sdk.XunitException($"Сервис {name} не найден в health-ответе"); - } - - // Логинит оператора (ожидается 200). - private static async Task LoginOperatorAsync(HttpClient client, string baseAddress) - { - using HttpResponseMessage login = await PostJsonAsync( - client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword }); - Assert.Equal(HttpStatusCode.OK, login.StatusCode); - } - - // Прогоняет сценарий на хосте с активным оператором operator/operator (реестр/лимиты пустые). - private static Task RunAsync( - Func scenario) => - OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario); - - // Фейк-хранилище оператора с активным оператором operator/operator. - private static FakeOperatorAuthStore NewOperatorStore() - { - var passwordHasher = new FakePasswordHasher(); - var store = new FakeOperatorAuthStore(); - store.AddOperator(new StoredOperatorDto( - Guid.NewGuid(), - OperatorLogin, - Status: "active", - PasswordHash: passwordHasher.Hash(OperatorPassword))); - return store; - } - - // HTTP-клиент с собственным CookieContainer (изоляция сценариев кук). - private static HttpClient CreateClient(string baseAddress) => - new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) - { - BaseAddress = new Uri(baseAddress), - }; - - // POST JSON-тела и возврат ответа. - private static async Task PostJsonAsync(HttpClient client, string url, object body) - { - string json = JsonSerializer.Serialize(body); - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - return await client.PostAsync(url, content); - } - - // Читает тело ответа как JSON-документ (корень, отвязанный от документа). - private static async Task ReadJsonAsync(HttpResponseMessage response) - { - await using Stream stream = await response.Content.ReadAsStreamAsync(); - using var document = await JsonDocument.ParseAsync(stream); - return document.RootElement.Clone(); - } -} +using System.Net; +using System.Text; +using System.Text.Json; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Tests.Unit; + +/// +/// HTTP-тесты операторского health: GET /api/operator/health (план Task 10, Ruling 3/6/9/11). +/// +/// +/// Прогон на in-process Kestrel (OperatorAuthHttpHost) в dev-конфигурации по умолчанию (UseLocal=true для +/// ml/ai/telegram — Local-режим). Проверяются форма ответа {ok, core:{db}, services:[…]}, пометка сервисов +/// local (reachable=false) и 401 без операторской сессии. Проверка БД (SELECT 1 к dev-Postgres :5433) в тесте +/// ходит на реальный порт: при выключенном docker core.db=down (ручка жива, 200); при поднятом deal-postgres — +/// ok. Реальный gRPC-health сервисов (UseLocal=false, поднятый стек) — ⚠ Manual, как в задачах 6–9. +/// +public sealed class OperatorHealthEndpointsHttpTests +{ + private const string OperatorLogin = "operator"; + private const string OperatorPassword = "operator"; + + [Fact] + public async Task Health_WithoutOperatorSession_Returns401() + { + await RunAsync( + async (baseAddress, _, _, _, _, _, _) => + { + using HttpResponseMessage response = await CreateClient(baseAddress).GetAsync($"{baseAddress}/api/operator/health"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal("Требуется вход оператора", (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task Health_InLocalMode_MarksServicesAsLocal_AndReturns200() + { + await RunAsync( + async (baseAddress, _, _, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage response = await operatorClient.GetAsync($"{baseAddress}/api/operator/health"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + JsonElement body = await ReadJsonAsync(response); + + // ok — сводный признак (БД может быть down без поднятого Postgres — сам факт 200 проверяется выше). + Assert.True(body.TryGetProperty("ok", out _)); + + // core.db — статус БД в допуске {ok, down}; ручка не падает при недоступном Postgres. + string databaseStatus = body.GetProperty("core").GetProperty("db").GetString()!; + Assert.True(databaseStatus is "ok" or "down", $"Неожиданный статус БД: {databaseStatus}"); + + // Сервисы в dev-Local-режиме помечены local (reachable=false): форма {name, mode, status, reachable}. + JsonElement services = body.GetProperty("services"); + Assert.Equal(3, services.GetArrayLength()); + AssertServiceLocal(FindService(services, "ml")); + AssertServiceLocal(FindService(services, "ai")); + AssertServiceLocal(FindService(services, "telegram")); + + // Глубины очередей и активные сессии (§10.2): числовые поля присутствуют всегда (0 при пустой БД). + JsonElement queues = body.GetProperty("queues"); + Assert.True(queues.GetProperty("pipeline").TryGetInt64(out _)); + Assert.True(queues.GetProperty("mlOutbox").TryGetInt64(out _)); + Assert.True(body.GetProperty("sessions").GetProperty("active").TryGetInt32(out _)); + }); + } + + // Форма записи сервиса в Local-режиме (план Task 10: {reachable:false, mode:"local"} + status/local). + private static void AssertServiceLocal(JsonElement service) + { + Assert.Equal("local", service.GetProperty("mode").GetString()); + Assert.Equal("local", service.GetProperty("status").GetString()); + Assert.False(service.GetProperty("reachable").GetBoolean()); + } + + // Запись services по имени сервиса (ml/ai/telegram). + private static JsonElement FindService(JsonElement services, string name) + { + foreach (JsonElement service in services.EnumerateArray()) + { + if (service.GetProperty("name").GetString() == name) + { + return service; + } + } + + throw new Xunit.Sdk.XunitException($"Сервис {name} не найден в health-ответе"); + } + + // Логинит оператора (ожидается 200). + private static async Task LoginOperatorAsync(HttpClient client, string baseAddress) + { + using HttpResponseMessage login = await PostJsonAsync( + client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword }); + Assert.Equal(HttpStatusCode.OK, login.StatusCode); + } + + // Прогоняет сценарий на хосте с активным оператором operator/operator (реестр/лимиты пустые). + private static Task RunAsync( + Func scenario) => + OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario); + + // Фейк-хранилище оператора с активным оператором operator/operator. + private static FakeOperatorAuthStore NewOperatorStore() + { + var passwordHasher = new FakePasswordHasher(); + var store = new FakeOperatorAuthStore(); + store.AddOperator(new StoredOperatorDto( + Guid.NewGuid(), + OperatorLogin, + Status: "active", + PasswordHash: passwordHasher.Hash(OperatorPassword))); + return store; + } + + // HTTP-клиент с собственным CookieContainer (изоляция сценариев кук). + private static HttpClient CreateClient(string baseAddress) => + new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) + { + BaseAddress = new Uri(baseAddress), + }; + + // POST JSON-тела и возврат ответа. + private static async Task PostJsonAsync(HttpClient client, string url, object body) + { + string json = JsonSerializer.Serialize(body); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + return await client.PostAsync(url, content); + } + + // Читает тело ответа как JSON-документ (корень, отвязанный от документа). + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + await using Stream stream = await response.Content.ReadAsStreamAsync(); + using var document = await JsonDocument.ParseAsync(stream); + return document.RootElement.Clone(); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/OperatorInvitesEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/OperatorInvitesEndpointsHttpTests.cs index 010e3d8..2fa854e 100644 --- a/src/core/tests/Deal.Tests.Unit/OperatorInvitesEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/OperatorInvitesEndpointsHttpTests.cs @@ -1,305 +1,308 @@ -using System.Net; -using System.Text; -using System.Text.Json; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Tests.Unit; - -/// -/// HTTP-тесты операторских ручек приглашений (Task 5, Ruling 2/11): create → list → revoke и 401 без оператора. -/// -/// -/// Эквивалент curl-минимума плана (create → list → revoke, 401 без оператора) на in-process Kestrel -/// (OperatorAuthHttpHost) с фейк-хранилищами: проверяются форма ответа create ({code,email,tenantId,expiresAt, -/// status}), список, отзыв и аудит invite_created/invite_revoked (email+code в DetailJson). Живая curl-приёмка -/// на :5080 — ⚠ Manual (нужен Postgres). -/// -public sealed class OperatorInvitesEndpointsHttpTests -{ - private const string OperatorLogin = "operator"; - private const string OperatorPassword = "operator"; - private const string InvitedEmail = "new-user@example.com"; - private const string OperatorUnauthorizedDetail = "Требуется вход оператора"; - private const string DuplicateActiveDetail = "Для этого email уже есть активное приглашение"; - private const string InviteNotPendingDetail = "Отозвать можно только ожидающее активации приглашение"; - private const string InviteNotFoundDetail = "Приглашение не найдено"; - private const string InvalidEmailDetail = "Некорректный email"; - - [Fact] - public async Task Create_WithoutOperatorSession_Returns401() - { - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, _, _) => - { - using HttpResponseMessage response = await PostJsonAsync( - CreateClient(baseAddress), $"{baseAddress}/api/operator/invites", new { email = InvitedEmail }); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - Assert.Equal(OperatorUnauthorizedDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task List_WithoutOperatorSession_Returns401() - { - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, _, _) => - { - using HttpResponseMessage response = await CreateClient(baseAddress).GetAsync($"{baseAddress}/api/operator/invites"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - }); - } - - [Fact] - public async Task Revoke_WithoutOperatorSession_Returns401() - { - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, _, _) => - { - using HttpResponseMessage response = await CreateClient(baseAddress).PostAsync( - $"{baseAddress}/api/operator/invites/some-code/revoke", content: null); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - }); - } - - [Fact] - public async Task CreateThenListThenRevoke_FullOperatorFlow_WorksAndWritesAudit() - { - var auditStore = new FakeAuditLogStore(); - - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, operatorStore, _, inviteStore, _) => - { - HttpClient client = CreateClient(baseAddress); - await LoginOperatorAsync(client, baseAddress); - - // 1. Create: форма ответа {code, email, tenantId, expiresAt, status}. - using HttpResponseMessage create = await PostJsonAsync( - client, $"{baseAddress}/api/operator/invites", new { email = InvitedEmail }); - Assert.Equal(HttpStatusCode.OK, create.StatusCode); - JsonElement created = await ReadJsonAsync(create); - string code = created.GetProperty("code").GetString()!; - Assert.Equal(InviteCodeGenerator.CodeLength, code.Length); - Assert.Equal(InvitedEmail, created.GetProperty("email").GetString()); - Assert.Equal(JsonValueKind.Null, created.GetProperty("tenantId").ValueKind); - Assert.True(DateTimeOffset.TryParse(created.GetProperty("expiresAt").GetString(), out var expiresAt)); - Assert.InRange(expiresAt, DateTimeOffset.UtcNow.AddHours(InvitesService.ExpiryHours - 1), DateTimeOffset.UtcNow.AddHours(InvitesService.ExpiryHours)); - Assert.Equal(InviteStatuses.Pending, created.GetProperty("status").GetString()); - - InviteDto stored = Assert.Single(inviteStore.Invites); - Assert.Equal(code, stored.Code); - Assert.Equal(operatorStore.Operators.Single().Id, stored.CreatedById); - - // Аудит создания: invite_created с email+code (Ruling 4). - AuditRecordDto createdAudit = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.InviteCreated); - Assert.Equal(AuditActorTypes.Operator, createdAudit.ActorType); - Assert.Equal(operatorStore.Operators.Single().Id, createdAudit.ActorId); - AssertDetailHasEmailAndCode(createdAudit, InvitedEmail, code); - - // 2. List: созданное приглашение видно. - using HttpResponseMessage list = await client.GetAsync($"{baseAddress}/api/operator/invites"); - Assert.Equal(HttpStatusCode.OK, list.StatusCode); - JsonElement listBody = await ReadJsonAsync(list); - JsonElement item = listBody.GetProperty("items").EnumerateArray().Single(); - Assert.Equal(code, item.GetProperty("code").GetString()); - Assert.Equal(InviteStatuses.Pending, item.GetProperty("status").GetString()); - - // 3. Revoke: {ok:true}, статус revoked, аудит invite_revoked. - using HttpResponseMessage revoke = await client.PostAsync( - $"{baseAddress}/api/operator/invites/{code}/revoke", content: null); - Assert.Equal(HttpStatusCode.OK, revoke.StatusCode); - Assert.True((await ReadJsonAsync(revoke)).GetProperty("ok").GetBoolean()); - - Assert.Equal(InviteStatuses.Revoked, inviteStore.Invites.Single().Status); - AuditRecordDto revokedAudit = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.InviteRevoked); - AssertDetailHasEmailAndCode(revokedAudit, InvitedEmail, code); - - // 4. Повторный отзыв — 400 (уже не pending). - using HttpResponseMessage revokeAgain = await client.PostAsync( - $"{baseAddress}/api/operator/invites/{code}/revoke", content: null); - Assert.Equal(HttpStatusCode.BadRequest, revokeAgain.StatusCode); - Assert.Equal(InviteNotPendingDetail, (await ReadJsonAsync(revokeAgain)).GetProperty("detail").GetString()); - }, - auditStore); - } - - [Fact] - public async Task Create_ForExistingTenant_ReturnsTenantIdInResponse() - { - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, _, _, _, _) => - { - HttpClient client = CreateClient(baseAddress); - await LoginOperatorAsync(client, baseAddress); - var tenantId = Guid.NewGuid(); - - using HttpResponseMessage create = await PostJsonAsync( - client, $"{baseAddress}/api/operator/invites", new { email = InvitedEmail, tenantId }); - - Assert.Equal(HttpStatusCode.OK, create.StatusCode); - JsonElement body = await ReadJsonAsync(create); - Assert.Equal(tenantId, body.GetProperty("tenantId").GetGuid()); - Assert.Equal(InviteStatuses.Pending, body.GetProperty("status").GetString()); - }); - } - - [Fact] - public async Task Create_WithDuplicateActiveEmail_Returns400WithPlanText() - { - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, _, _, _, _) => - { - HttpClient client = CreateClient(baseAddress); - await LoginOperatorAsync(client, baseAddress); - - using (HttpResponseMessage first = await PostJsonAsync( - client, $"{baseAddress}/api/operator/invites", new { email = InvitedEmail })) - { - Assert.Equal(HttpStatusCode.OK, first.StatusCode); - } - - using HttpResponseMessage duplicate = await PostJsonAsync( - client, $"{baseAddress}/api/operator/invites", new { email = InvitedEmail.ToUpperInvariant() }); - Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode); - Assert.Equal(DuplicateActiveDetail, (await ReadJsonAsync(duplicate)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task Create_AfterRevoke_SameEmailAllowsNewInvite() - { - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, _, _, _, _) => - { - HttpClient client = CreateClient(baseAddress); - await LoginOperatorAsync(client, baseAddress); - - using HttpResponseMessage create = await PostJsonAsync( - client, $"{baseAddress}/api/operator/invites", new { email = InvitedEmail }); - string code = (await ReadJsonAsync(create)).GetProperty("code").GetString()!; - using HttpResponseMessage revoke = await client.PostAsync( - $"{baseAddress}/api/operator/invites/{code}/revoke", content: null); - Assert.Equal(HttpStatusCode.OK, revoke.StatusCode); - - // Отозванное приглашение освобождает email (Ruling 2; план Task 5: revoked позволяет новый). - using HttpResponseMessage second = await PostJsonAsync( - client, $"{baseAddress}/api/operator/invites", new { email = InvitedEmail }); - Assert.Equal(HttpStatusCode.OK, second.StatusCode); - Assert.NotEqual(code, (await ReadJsonAsync(second)).GetProperty("code").GetString()); - }); - } - - [Fact] - public async Task Create_WithInvalidEmail_Returns400() - { - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, _, _, _, _) => - { - HttpClient client = CreateClient(baseAddress); - await LoginOperatorAsync(client, baseAddress); - - using HttpResponseMessage response = await PostJsonAsync( - client, $"{baseAddress}/api/operator/invites", new { email = "not-an-email" }); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal(InvalidEmailDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task Revoke_WithUnknownCode_Returns404() - { - await OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, _, _, _, _) => - { - HttpClient client = CreateClient(baseAddress); - await LoginOperatorAsync(client, baseAddress); - - using HttpResponseMessage response = await client.PostAsync( - $"{baseAddress}/api/operator/invites/no-such-code/revoke", content: null); - - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - Assert.Equal(InviteNotFoundDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - // ─── Хелперы ───────────────────────────────────────────────────────── - - // Проверяет DetailJson записи аудита: поля email и codeHash (Security review: код — хэш). - private static void AssertDetailHasEmailAndCode(AuditRecordDto record, string email, string code) - { - Assert.NotNull(record.DetailJson); - using var document = JsonDocument.Parse(record.DetailJson!); - JsonElement root = document.RootElement; - Assert.Equal(email, root.GetProperty("email").GetString()); - Assert.Equal(SessionTokens.HashToken(code), root.GetProperty("codeHash").GetString()); - } - - // Логинит оператора (ожидается 200). - private static async Task LoginOperatorAsync(HttpClient client, string baseAddress) - { - using HttpResponseMessage login = await PostJsonAsync( - client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword }); - Assert.Equal(HttpStatusCode.OK, login.StatusCode); - } - - // Фейк-хранилище оператора с активным оператором operator/operator. - private static FakeOperatorAuthStore NewOperatorStore() - { - var passwordHasher = new FakePasswordHasher(); - var store = new FakeOperatorAuthStore(); - store.AddOperator(new StoredOperatorDto( - Guid.NewGuid(), - OperatorLogin, - Status: "active", - PasswordHash: passwordHasher.Hash(OperatorPassword))); - return store; - } - - // Пустое фейк-хранилище пользователей (тенантные ручки в этих сценариях не используются). - private static FakeAuthStore NewUserStore() => new(); - - // HTTP-клиент с собственным CookieContainer (изоляция сценариев кук). - private static HttpClient CreateClient(string baseAddress) => - new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) - { - BaseAddress = new Uri(baseAddress), - }; - - // POST JSON-тела и возврат ответа. - private static async Task PostJsonAsync(HttpClient client, string url, object body) - { - string json = JsonSerializer.Serialize(body); - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - return await client.PostAsync(url, content); - } - - // Читает тело ответа как JSON-документ (корень, отвязанный от документа). - private static async Task ReadJsonAsync(HttpResponseMessage response) - { - await using Stream stream = await response.Content.ReadAsStreamAsync(); - using var document = await JsonDocument.ParseAsync(stream); - return document.RootElement.Clone(); - } -} +using System.Net; +using System.Text; +using System.Text.Json; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Tests.Unit; + +/// +/// HTTP-тесты операторских ручек приглашений (Task 5, Ruling 2/11): create → list → revoke и 401 без оператора. +/// +/// +/// Эквивалент curl-минимума плана (create → list → revoke, 401 без оператора) на in-process Kestrel +/// (OperatorAuthHttpHost) с фейк-хранилищами: проверяются форма ответа create ({code,email,tenantId,expiresAt, +/// status}), список, отзыв и аудит invite_created/invite_revoked (email+code в DetailJson). Живая curl-приёмка +/// на :5080 — ⚠ Manual (нужен Postgres). +/// +public sealed class OperatorInvitesEndpointsHttpTests +{ + private const string OperatorLogin = "operator"; + private const string OperatorPassword = "operator"; + private const string InvitedEmail = "new-user@example.com"; + private const string OperatorUnauthorizedDetail = "Требуется вход оператора"; + private const string DuplicateActiveDetail = "Для этого email уже есть активное приглашение"; + private const string InviteNotPendingDetail = "Отозвать можно только ожидающее активации приглашение"; + private const string InviteNotFoundDetail = "Приглашение не найдено"; + private const string InvalidEmailDetail = "Некорректный email"; + + [Fact] + public async Task Create_WithoutOperatorSession_Returns401() + { + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, _, _) => + { + using HttpResponseMessage response = await PostJsonAsync( + CreateClient(baseAddress), $"{baseAddress}/api/operator/invites", new { email = InvitedEmail }); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal(OperatorUnauthorizedDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task List_WithoutOperatorSession_Returns401() + { + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, _, _) => + { + using HttpResponseMessage response = await CreateClient(baseAddress).GetAsync($"{baseAddress}/api/operator/invites"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + }); + } + + [Fact] + public async Task Revoke_WithoutOperatorSession_Returns401() + { + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, _, _) => + { + using HttpResponseMessage response = await CreateClient(baseAddress).PostAsync( + $"{baseAddress}/api/operator/invites/some-code/revoke", content: null); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + }); + } + + [Fact] + public async Task CreateThenListThenRevoke_FullOperatorFlow_WorksAndWritesAudit() + { + var auditStore = new FakeAuditLogStore(); + + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, operatorStore, _, inviteStore, _) => + { + HttpClient client = CreateClient(baseAddress); + await LoginOperatorAsync(client, baseAddress); + + // 1. Create: форма ответа {code, email, tenantId, expiresAt, status}. + using HttpResponseMessage create = await PostJsonAsync( + client, $"{baseAddress}/api/operator/invites", new { email = InvitedEmail }); + Assert.Equal(HttpStatusCode.OK, create.StatusCode); + JsonElement created = await ReadJsonAsync(create); + string code = created.GetProperty("code").GetString()!; + Assert.Equal(InviteCodeGenerator.CodeLength, code.Length); + Assert.Equal(InvitedEmail, created.GetProperty("email").GetString()); + Assert.Equal(JsonValueKind.Null, created.GetProperty("tenantId").ValueKind); + Assert.True(DateTimeOffset.TryParse(created.GetProperty("expiresAt").GetString(), out var expiresAt)); + Assert.InRange(expiresAt, DateTimeOffset.UtcNow.AddHours(InvitesService.ExpiryHours - 1), DateTimeOffset.UtcNow.AddHours(InvitesService.ExpiryHours)); + Assert.Equal(InviteStatuses.Pending, created.GetProperty("status").GetString()); + + InviteDto stored = Assert.Single(inviteStore.Invites); + Assert.Equal(code, stored.Code); + Assert.Equal(operatorStore.Operators.Single().Id, stored.CreatedById); + + // Аудит создания: invite_created с email+code (Ruling 4). + AuditRecordDto createdAudit = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.InviteCreated); + Assert.Equal(AuditActorTypes.Operator, createdAudit.ActorType); + Assert.Equal(operatorStore.Operators.Single().Id, createdAudit.ActorId); + AssertDetailHasEmailAndCode(createdAudit, InvitedEmail, code); + + // 2. List: созданное приглашение видно. + using HttpResponseMessage list = await client.GetAsync($"{baseAddress}/api/operator/invites"); + Assert.Equal(HttpStatusCode.OK, list.StatusCode); + JsonElement listBody = await ReadJsonAsync(list); + JsonElement item = listBody.GetProperty("items").EnumerateArray().Single(); + Assert.Equal(code, item.GetProperty("code").GetString()); + Assert.Equal(InviteStatuses.Pending, item.GetProperty("status").GetString()); + + // 3. Revoke: {ok:true}, статус revoked, аудит invite_revoked. + using HttpResponseMessage revoke = await client.PostAsync( + $"{baseAddress}/api/operator/invites/{code}/revoke", content: null); + Assert.Equal(HttpStatusCode.OK, revoke.StatusCode); + Assert.True((await ReadJsonAsync(revoke)).GetProperty("ok").GetBoolean()); + + Assert.Equal(InviteStatuses.Revoked, inviteStore.Invites.Single().Status); + AuditRecordDto revokedAudit = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.InviteRevoked); + AssertDetailHasEmailAndCode(revokedAudit, InvitedEmail, code); + + // 4. Повторный отзыв — 400 (уже не pending). + using HttpResponseMessage revokeAgain = await client.PostAsync( + $"{baseAddress}/api/operator/invites/{code}/revoke", content: null); + Assert.Equal(HttpStatusCode.BadRequest, revokeAgain.StatusCode); + Assert.Equal(InviteNotPendingDetail, (await ReadJsonAsync(revokeAgain)).GetProperty("detail").GetString()); + }, + auditStore); + } + + [Fact] + public async Task Create_ForExistingTenant_ReturnsTenantIdInResponse() + { + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, _, _, _, _) => + { + HttpClient client = CreateClient(baseAddress); + await LoginOperatorAsync(client, baseAddress); + var tenantId = Guid.NewGuid(); + + using HttpResponseMessage create = await PostJsonAsync( + client, $"{baseAddress}/api/operator/invites", new { email = InvitedEmail, tenantId }); + + Assert.Equal(HttpStatusCode.OK, create.StatusCode); + JsonElement body = await ReadJsonAsync(create); + Assert.Equal(tenantId, body.GetProperty("tenantId").GetGuid()); + Assert.Equal(InviteStatuses.Pending, body.GetProperty("status").GetString()); + }); + } + + [Fact] + public async Task Create_WithDuplicateActiveEmail_Returns400WithPlanText() + { + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, _, _, _, _) => + { + HttpClient client = CreateClient(baseAddress); + await LoginOperatorAsync(client, baseAddress); + + using (HttpResponseMessage first = await PostJsonAsync( + client, $"{baseAddress}/api/operator/invites", new { email = InvitedEmail })) + { + Assert.Equal(HttpStatusCode.OK, first.StatusCode); + } + + using HttpResponseMessage duplicate = await PostJsonAsync( + client, $"{baseAddress}/api/operator/invites", new { email = InvitedEmail.ToUpperInvariant() }); + Assert.Equal(HttpStatusCode.BadRequest, duplicate.StatusCode); + Assert.Equal(DuplicateActiveDetail, (await ReadJsonAsync(duplicate)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task Create_AfterRevoke_SameEmailAllowsNewInvite() + { + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, _, _, _, _) => + { + HttpClient client = CreateClient(baseAddress); + await LoginOperatorAsync(client, baseAddress); + + using HttpResponseMessage create = await PostJsonAsync( + client, $"{baseAddress}/api/operator/invites", new { email = InvitedEmail }); + string code = (await ReadJsonAsync(create)).GetProperty("code").GetString()!; + using HttpResponseMessage revoke = await client.PostAsync( + $"{baseAddress}/api/operator/invites/{code}/revoke", content: null); + Assert.Equal(HttpStatusCode.OK, revoke.StatusCode); + + // Отозванное приглашение освобождает email (Ruling 2; план Task 5: revoked позволяет новый). + using HttpResponseMessage second = await PostJsonAsync( + client, $"{baseAddress}/api/operator/invites", new { email = InvitedEmail }); + Assert.Equal(HttpStatusCode.OK, second.StatusCode); + Assert.NotEqual(code, (await ReadJsonAsync(second)).GetProperty("code").GetString()); + }); + } + + [Fact] + public async Task Create_WithInvalidEmail_Returns400() + { + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, _, _, _, _) => + { + HttpClient client = CreateClient(baseAddress); + await LoginOperatorAsync(client, baseAddress); + + using HttpResponseMessage response = await PostJsonAsync( + client, $"{baseAddress}/api/operator/invites", new { email = "not-an-email" }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(InvalidEmailDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task Revoke_WithUnknownCode_Returns404() + { + await OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, _, _, _, _) => + { + HttpClient client = CreateClient(baseAddress); + await LoginOperatorAsync(client, baseAddress); + + using HttpResponseMessage response = await client.PostAsync( + $"{baseAddress}/api/operator/invites/no-such-code/revoke", content: null); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(InviteNotFoundDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + // ─── Хелперы ───────────────────────────────────────────────────────── + + // Проверяет DetailJson записи аудита: поля email и codeHash (Security review: код — хэш). + private static void AssertDetailHasEmailAndCode(AuditRecordDto record, string email, string code) + { + Assert.NotNull(record.DetailJson); + using var document = JsonDocument.Parse(record.DetailJson!); + JsonElement root = document.RootElement; + Assert.Equal(email, root.GetProperty("email").GetString()); + Assert.Equal(SessionTokens.HashToken(code), root.GetProperty("codeHash").GetString()); + } + + // Логинит оператора (ожидается 200). + private static async Task LoginOperatorAsync(HttpClient client, string baseAddress) + { + using HttpResponseMessage login = await PostJsonAsync( + client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword }); + Assert.Equal(HttpStatusCode.OK, login.StatusCode); + } + + // Фейк-хранилище оператора с активным оператором operator/operator. + private static FakeOperatorAuthStore NewOperatorStore() + { + var passwordHasher = new FakePasswordHasher(); + var store = new FakeOperatorAuthStore(); + store.AddOperator(new StoredOperatorDto( + Guid.NewGuid(), + OperatorLogin, + Status: "active", + PasswordHash: passwordHasher.Hash(OperatorPassword))); + return store; + } + + // Пустое фейк-хранилище пользователей (тенантные ручки в этих сценариях не используются). + private static FakeAuthStore NewUserStore() => new(); + + // HTTP-клиент с собственным CookieContainer (изоляция сценариев кук). + private static HttpClient CreateClient(string baseAddress) => + new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) + { + BaseAddress = new Uri(baseAddress), + }; + + // POST JSON-тела и возврат ответа. + private static async Task PostJsonAsync(HttpClient client, string url, object body) + { + string json = JsonSerializer.Serialize(body); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + return await client.PostAsync(url, content); + } + + // Читает тело ответа как JSON-документ (корень, отвязанный от документа). + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + await using Stream stream = await response.Content.ReadAsStreamAsync(); + using var document = await JsonDocument.ParseAsync(stream); + return document.RootElement.Clone(); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/OperatorLimitsEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/OperatorLimitsEndpointsHttpTests.cs index 24f10c9..9808988 100644 --- a/src/core/tests/Deal.Tests.Unit/OperatorLimitsEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/OperatorLimitsEndpointsHttpTests.cs @@ -1,382 +1,385 @@ -using System.Net; -using System.Text; -using System.Text.Json; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Tests.Unit; - -/// -/// HTTP-тесты операторских ручек лимитов ИИ-бюджета (план Task 10, Ruling 3/4/11): сводка по всем -/// тенантам и просмотр/смена лимита (GET/PATCH /api/operator/tenants/{id}/limit). -/// -/// -/// Прогон на in-process Kestrel (OperatorAuthHttpHost) с фейк-хранилищами: реестр тенантов и лимиты — фейки -/// (FakeTenantStore/FakeTenantLimitStore), аудит — настоящий сервис модуля на фейк-сторе. Проверяются формы -/// ответов, сброс флагов Warned80/NotifiedExhausted при смене бюджета, аудит tenant_limit_changed (только при -/// реальном изменении), 401 без операторской сессии и 400/404 на невалидные тела. Живая curl-приёмка на :5080 -/// (с psql-проверкой строки public.tenant_limits) — ⚠ Manual (нужен Postgres). -/// -public sealed class OperatorLimitsEndpointsHttpTests -{ - private const string OperatorLogin = "operator"; - private const string OperatorPassword = "operator"; - private const string TenantNotFoundDetail = "Тенант не найден"; - private const string EmptyUpdateDetail = "Укажите новый бюджет или период"; - private const string NegativeBudgetDetail = "Бюджет должен быть неотрицательным"; - private const string InvalidPeriodDetail = "Период должен быть month или day"; - - private static readonly Guid FirstTenantId = Guid.NewGuid(); - private static readonly Guid SecondTenantId = Guid.NewGuid(); - private const string FirstTenantName = "Тенант-альфа"; - private const string SecondTenantName = "Тенант-бета"; - private const long DefaultBudgetTokens = 10_000_000; - - [Fact] - public async Task Limits_WithoutOperatorSession_Returns401() - { - await RunAsync( - async (baseAddress, _, _, _, _, _, _) => - { - using HttpClient anonymous = CreateClient(baseAddress); - using HttpResponseMessage summary = await anonymous.GetAsync($"{baseAddress}/api/operator/limits"); - Assert.Equal(HttpStatusCode.Unauthorized, summary.StatusCode); - Assert.Equal("Требуется вход оператора", (await ReadJsonAsync(summary)).GetProperty("detail").GetString()); - - using HttpResponseMessage detail = await anonymous.GetAsync(LimitUrl(baseAddress, FirstTenantId)); - Assert.Equal(HttpStatusCode.Unauthorized, detail.StatusCode); - - using HttpResponseMessage patch = await PatchJsonAsync( - anonymous, LimitUrl(baseAddress, FirstTenantId), new { budget = 5_000 }); - Assert.Equal(HttpStatusCode.Unauthorized, patch.StatusCode); - }); - } - - [Fact] - public async Task ListSummary_ReturnsItemsWithBudgetUsedPercentAndStatus() - { - await RunAsync( - async (baseAddress, _, _, _, _, _, limitStore) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage response = await operatorClient.GetAsync($"{baseAddress}/api/operator/limits"); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - JsonElement body = await ReadJsonAsync(response); - JsonElement items = body.GetProperty("items"); - Assert.Equal(2, items.GetArrayLength()); - - JsonElement first = FindItem(items, FirstTenantId); - Assert.Equal(FirstTenantName, first.GetProperty("name").GetString()); - Assert.Equal(DefaultBudgetTokens, first.GetProperty("budget").GetInt64()); - Assert.Equal(TenantLimitPeriods.Month, first.GetProperty("period").GetString()); - Assert.Equal(8_000_000, first.GetProperty("used").GetInt64()); - Assert.Equal(80, first.GetProperty("percent").GetInt32()); - Assert.Equal(TenantStatuses.Active, first.GetProperty("status").GetString()); - - JsonElement second = FindItem(items, SecondTenantId); - Assert.Equal(SecondTenantName, second.GetProperty("name").GetString()); - Assert.Equal(100_000, second.GetProperty("budget").GetInt64()); - Assert.Equal(0, second.GetProperty("percent").GetInt32()); - }); - } - - [Fact] - public async Task GetLimit_ReturnsDetailWithFlagsAndThresholds() - { - await RunAsync( - async (baseAddress, _, _, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage response = await operatorClient.GetAsync(LimitUrl(baseAddress, FirstTenantId)); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - JsonElement body = await ReadJsonAsync(response); - Assert.Equal(FirstTenantId, body.GetProperty("tenantId").GetGuid()); - Assert.Equal(FirstTenantName, body.GetProperty("name").GetString()); - Assert.Equal(TenantStatuses.Active, body.GetProperty("status").GetString()); - Assert.True(body.GetProperty("allowed").GetBoolean()); - Assert.Equal(DefaultBudgetTokens, body.GetProperty("budget").GetInt64()); - Assert.Equal(TenantLimitPeriods.Month, body.GetProperty("period").GetString()); - Assert.Equal(8_000_000, body.GetProperty("used").GetInt64()); - Assert.Equal(2_000_000, body.GetProperty("remaining").GetInt64()); - Assert.Equal(80, body.GetProperty("percent").GetInt32()); - Assert.True(body.GetProperty("warned80").GetBoolean()); - Assert.False(body.GetProperty("notifiedExhausted").GetBoolean()); - Assert.True(DateTimeOffset.TryParse(body.GetProperty("periodStart").GetString(), out _)); - }); - } - - [Fact] - public async Task GetLimit_ForUnknownTenant_Returns404() - { - await RunAsync( - async (baseAddress, _, _, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage response = await operatorClient.GetAsync(LimitUrl(baseAddress, Guid.NewGuid())); - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - Assert.Equal(TenantNotFoundDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task PatchLimit_ResetsFlags_UpdatesBudgetAndWritesAudit() - { - var auditStore = new FakeAuditLogStore(); - const long newBudget = 20_000_000; - - await RunAsync( - async (baseAddress, operatorStore, _, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage patch = await PatchJsonAsync( - operatorClient, LimitUrl(baseAddress, FirstTenantId), new { budget = newBudget }); - Assert.Equal(HttpStatusCode.OK, patch.StatusCode); - JsonElement body = await ReadJsonAsync(patch); - Assert.Equal(newBudget, body.GetProperty("budget").GetInt64()); - Assert.Equal(40, body.GetProperty("percent").GetInt32()); // 8 000 000 / 20 000 000 - Assert.False(body.GetProperty("warned80").GetBoolean()); - Assert.False(body.GetProperty("notifiedExhausted").GetBoolean()); - - // Повторное чтение: строка реально изменена (сброс флагов виден и на GET). - using HttpResponseMessage get = await operatorClient.GetAsync(LimitUrl(baseAddress, FirstTenantId)); - JsonElement stored = await ReadJsonAsync(get); - Assert.Equal(newBudget, stored.GetProperty("budget").GetInt64()); - Assert.False(stored.GetProperty("warned80").GetBoolean()); - Assert.False(stored.GetProperty("notifiedExhausted").GetBoolean()); - - // Аудит tenant_limit_changed: актор-оператор, детали старый/новый бюджет. - AuditRecordDto audit = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.TenantLimitChanged); - Assert.Equal(AuditActorTypes.Operator, audit.ActorType); - Assert.Equal(operatorStore.Operators.Single().Id, audit.ActorId); - Assert.Equal(FirstTenantId, audit.TenantId); - Assert.NotNull(audit.DetailJson); - using (var detail = JsonDocument.Parse(audit.DetailJson!)) - { - Assert.Equal(DefaultBudgetTokens, detail.RootElement.GetProperty("oldBudget").GetInt64()); - Assert.Equal(TenantLimitPeriods.Month, detail.RootElement.GetProperty("oldPeriod").GetString()); - Assert.Equal(newBudget, detail.RootElement.GetProperty("budgetTokens").GetInt64()); - Assert.Equal(TenantLimitPeriods.Month, detail.RootElement.GetProperty("period").GetString()); - } - }, - auditStore); - } - - [Fact] - public async Task PatchLimit_OnlyPeriod_KeepsBudgetAndWritesAudit() - { - var auditStore = new FakeAuditLogStore(); - - await RunAsync( - async (baseAddress, operatorStore, _, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage patch = await PatchJsonAsync( - operatorClient, LimitUrl(baseAddress, FirstTenantId), new { period = TenantLimitPeriods.Day }); - Assert.Equal(HttpStatusCode.OK, patch.StatusCode); - JsonElement body = await ReadJsonAsync(patch); - Assert.Equal(DefaultBudgetTokens, body.GetProperty("budget").GetInt64()); - Assert.Equal(TenantLimitPeriods.Day, body.GetProperty("period").GetString()); - - AuditRecordDto audit = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.TenantLimitChanged); - Assert.Equal(operatorStore.Operators.Single().Id, audit.ActorId); - Assert.NotNull(audit.DetailJson); - using (var detail = JsonDocument.Parse(audit.DetailJson!)) - { - Assert.Equal(DefaultBudgetTokens, detail.RootElement.GetProperty("budgetTokens").GetInt64()); - Assert.Equal(TenantLimitPeriods.Day, detail.RootElement.GetProperty("period").GetString()); - } - }, - auditStore); - } - - [Fact] - public async Task PatchLimit_WithSameValues_IsIdempotent_WithoutNewAudit() - { - var auditStore = new FakeAuditLogStore(); - const long budget = 25_000_000; - - await RunAsync( - async (baseAddress, _, _, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage firstPatch = await PatchJsonAsync( - operatorClient, LimitUrl(baseAddress, FirstTenantId), new { budget }); - Assert.Equal(HttpStatusCode.OK, firstPatch.StatusCode); - - using HttpResponseMessage repeatedPatch = await PatchJsonAsync( - operatorClient, LimitUrl(baseAddress, FirstTenantId), new { budget }); - Assert.Equal(HttpStatusCode.OK, repeatedPatch.StatusCode); - - // Реальное изменение — ровно одно; повторный PATCH с теми же значениями аудит не дублирует. - Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.TenantLimitChanged); - }, - auditStore); - } - - [Fact] - public async Task PatchLimit_InvalidBodies_Return400() - { - await RunAsync( - async (baseAddress, _, _, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - string url = LimitUrl(baseAddress, FirstTenantId); - - using HttpResponseMessage empty = await PatchJsonAsync(operatorClient, url, new { }); - Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode); - Assert.Equal(EmptyUpdateDetail, (await ReadJsonAsync(empty)).GetProperty("detail").GetString()); - - using HttpResponseMessage negative = await PatchJsonAsync(operatorClient, url, new { budget = -1 }); - Assert.Equal(HttpStatusCode.BadRequest, negative.StatusCode); - Assert.Equal(NegativeBudgetDetail, (await ReadJsonAsync(negative)).GetProperty("detail").GetString()); - - using HttpResponseMessage badPeriod = await PatchJsonAsync(operatorClient, url, new { period = "week" }); - Assert.Equal(HttpStatusCode.BadRequest, badPeriod.StatusCode); - Assert.Equal(InvalidPeriodDetail, (await ReadJsonAsync(badPeriod)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task PatchLimit_ForUnknownTenant_Returns404() - { - await RunAsync( - async (baseAddress, _, _, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage patch = await PatchJsonAsync( - operatorClient, LimitUrl(baseAddress, Guid.NewGuid()), new { budget = 5_000 }); - Assert.Equal(HttpStatusCode.NotFound, patch.StatusCode); - Assert.Equal(TenantNotFoundDetail, (await ReadJsonAsync(patch)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task PatchLimit_ZeroBudget_AllowsAndReturnsPercent100() - { - await RunAsync( - async (baseAddress, _, _, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - // Бюджет 0 — «ИИ запрещён» (Ruling 3): допустимое значение, расход 8 000 000 виден как 100%. - using HttpResponseMessage patch = await PatchJsonAsync( - operatorClient, LimitUrl(baseAddress, FirstTenantId), new { budget = 0 }); - Assert.Equal(HttpStatusCode.OK, patch.StatusCode); - JsonElement body = await ReadJsonAsync(patch); - Assert.Equal(0, body.GetProperty("budget").GetInt64()); - Assert.Equal(100, body.GetProperty("percent").GetInt32()); - Assert.False(body.GetProperty("allowed").GetBoolean()); - }); - } - - // ─── Хелперы ───────────────────────────────────────────────────────── - - // URL лимита тенанта (GET/PATCH /api/operator/tenants/{id}/limit). - private static string LimitUrl(string baseAddress, Guid tenantId) => - $"{baseAddress}/api/operator/tenants/{tenantId}/limit"; - - // Прогоняет сценарий на хосте с двумя активными тенантами и предзаполненными строками лимитов. - private static Task RunAsync( - Func scenario, - FakeAuditLogStore? auditStore = null) - { - // Первый тенант: расход 80% месячного бюджета (8 000 000 / 10 000 000) + флаг Warned80; - // второй: небольшой бюджет без расхода. Дата начала периода — текущая (reset не срабатывает). - var tenantStore = new FakeTenantStore( - new TenantRecordDto(FirstTenantId, FirstTenantName, TenantStatuses.Active, DateTimeOffset.UtcNow), - new TenantRecordDto(SecondTenantId, SecondTenantName, TenantStatuses.Active, DateTimeOffset.UtcNow)); - var limitStore = new FakeTenantLimitStore(); - limitStore.Preload(FirstTenantId, DefaultBudgetTokens, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 8_000_000, warned80: true); - limitStore.Preload(SecondTenantId, 100_000, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 0); - return OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - new FakeAuthStore(), - scenario, - auditStore, - tenantStore: tenantStore, - limitStore: limitStore); - } - - // Элемент сводки по идентификатору тенанта. - private static JsonElement FindItem(JsonElement items, Guid tenantId) - { - foreach (JsonElement item in items.EnumerateArray()) - { - if (item.GetProperty("tenantId").GetGuid() == tenantId) - { - return item; - } - } - - throw new Xunit.Sdk.XunitException($"Элемент сводки тенанта {tenantId} не найден"); - } - - // Логинит оператора (ожидается 200). - private static async Task LoginOperatorAsync(HttpClient client, string baseAddress) - { - using HttpResponseMessage login = await PostJsonAsync( - client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword }); - Assert.Equal(HttpStatusCode.OK, login.StatusCode); - } - - // Фейк-хранилище оператора с активным оператором operator/operator. - private static FakeOperatorAuthStore NewOperatorStore() - { - var passwordHasher = new FakePasswordHasher(); - var store = new FakeOperatorAuthStore(); - store.AddOperator(new StoredOperatorDto( - Guid.NewGuid(), - OperatorLogin, - Status: "active", - PasswordHash: passwordHasher.Hash(OperatorPassword))); - return store; - } - - // HTTP-клиент с собственным CookieContainer (изоляция сценариев кук). - private static HttpClient CreateClient(string baseAddress) => - new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) - { - BaseAddress = new Uri(baseAddress), - }; - - // POST JSON-тела и возврат ответа. - private static async Task PostJsonAsync(HttpClient client, string url, object body) - { - string json = JsonSerializer.Serialize(body); - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - return await client.PostAsync(url, content); - } - - // PATCH JSON-тела и возврат ответа. - private static async Task PatchJsonAsync(HttpClient client, string url, object body) - { - string json = JsonSerializer.Serialize(body); - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - using var request = new HttpRequestMessage(HttpMethod.Patch, url) { Content = content }; - return await client.SendAsync(request); - } - - // Читает тело ответа как JSON-документ (корень, отвязанный от документа). - private static async Task ReadJsonAsync(HttpResponseMessage response) - { - await using Stream stream = await response.Content.ReadAsStreamAsync(); - using var document = await JsonDocument.ParseAsync(stream); - return document.RootElement.Clone(); - } -} +using System.Net; +using System.Text; +using System.Text.Json; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Tests.Unit; + +/// +/// HTTP-тесты операторских ручек лимитов ИИ-бюджета (план Task 10, Ruling 3/4/11): сводка по всем +/// тенантам и просмотр/смена лимита (GET/PATCH /api/operator/tenants/{id}/limit). +/// +/// +/// Прогон на in-process Kestrel (OperatorAuthHttpHost) с фейк-хранилищами: реестр тенантов и лимиты — фейки +/// (FakeTenantStore/FakeTenantLimitStore), аудит — настоящий сервис модуля на фейк-сторе. Проверяются формы +/// ответов, сброс флагов Warned80/NotifiedExhausted при смене бюджета, аудит tenant_limit_changed (только при +/// реальном изменении), 401 без операторской сессии и 400/404 на невалидные тела. Живая curl-приёмка на :5080 +/// (с psql-проверкой строки public.tenant_limits) — ⚠ Manual (нужен Postgres). +/// +public sealed class OperatorLimitsEndpointsHttpTests +{ + private const string OperatorLogin = "operator"; + private const string OperatorPassword = "operator"; + private const string TenantNotFoundDetail = "Тенант не найден"; + private const string EmptyUpdateDetail = "Укажите новый бюджет или период"; + private const string NegativeBudgetDetail = "Бюджет должен быть неотрицательным"; + private const string InvalidPeriodDetail = "Период должен быть month или day"; + + private static readonly Guid FirstTenantId = Guid.NewGuid(); + private static readonly Guid SecondTenantId = Guid.NewGuid(); + private const string FirstTenantName = "Тенант-альфа"; + private const string SecondTenantName = "Тенант-бета"; + private const long DefaultBudgetTokens = 10_000_000; + + [Fact] + public async Task Limits_WithoutOperatorSession_Returns401() + { + await RunAsync( + async (baseAddress, _, _, _, _, _, _) => + { + using HttpClient anonymous = CreateClient(baseAddress); + using HttpResponseMessage summary = await anonymous.GetAsync($"{baseAddress}/api/operator/limits"); + Assert.Equal(HttpStatusCode.Unauthorized, summary.StatusCode); + Assert.Equal("Требуется вход оператора", (await ReadJsonAsync(summary)).GetProperty("detail").GetString()); + + using HttpResponseMessage detail = await anonymous.GetAsync(LimitUrl(baseAddress, FirstTenantId)); + Assert.Equal(HttpStatusCode.Unauthorized, detail.StatusCode); + + using HttpResponseMessage patch = await PatchJsonAsync( + anonymous, LimitUrl(baseAddress, FirstTenantId), new { budget = 5_000 }); + Assert.Equal(HttpStatusCode.Unauthorized, patch.StatusCode); + }); + } + + [Fact] + public async Task ListSummary_ReturnsItemsWithBudgetUsedPercentAndStatus() + { + await RunAsync( + async (baseAddress, _, _, _, _, _, limitStore) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage response = await operatorClient.GetAsync($"{baseAddress}/api/operator/limits"); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + JsonElement body = await ReadJsonAsync(response); + JsonElement items = body.GetProperty("items"); + Assert.Equal(2, items.GetArrayLength()); + + JsonElement first = FindItem(items, FirstTenantId); + Assert.Equal(FirstTenantName, first.GetProperty("name").GetString()); + Assert.Equal(DefaultBudgetTokens, first.GetProperty("budget").GetInt64()); + Assert.Equal(TenantLimitPeriods.Month, first.GetProperty("period").GetString()); + Assert.Equal(8_000_000, first.GetProperty("used").GetInt64()); + Assert.Equal(80, first.GetProperty("percent").GetInt32()); + Assert.Equal(TenantStatuses.Active, first.GetProperty("status").GetString()); + + JsonElement second = FindItem(items, SecondTenantId); + Assert.Equal(SecondTenantName, second.GetProperty("name").GetString()); + Assert.Equal(100_000, second.GetProperty("budget").GetInt64()); + Assert.Equal(0, second.GetProperty("percent").GetInt32()); + }); + } + + [Fact] + public async Task GetLimit_ReturnsDetailWithFlagsAndThresholds() + { + await RunAsync( + async (baseAddress, _, _, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage response = await operatorClient.GetAsync(LimitUrl(baseAddress, FirstTenantId)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + JsonElement body = await ReadJsonAsync(response); + Assert.Equal(FirstTenantId, body.GetProperty("tenantId").GetGuid()); + Assert.Equal(FirstTenantName, body.GetProperty("name").GetString()); + Assert.Equal(TenantStatuses.Active, body.GetProperty("status").GetString()); + Assert.True(body.GetProperty("allowed").GetBoolean()); + Assert.Equal(DefaultBudgetTokens, body.GetProperty("budget").GetInt64()); + Assert.Equal(TenantLimitPeriods.Month, body.GetProperty("period").GetString()); + Assert.Equal(8_000_000, body.GetProperty("used").GetInt64()); + Assert.Equal(2_000_000, body.GetProperty("remaining").GetInt64()); + Assert.Equal(80, body.GetProperty("percent").GetInt32()); + Assert.True(body.GetProperty("warned80").GetBoolean()); + Assert.False(body.GetProperty("notifiedExhausted").GetBoolean()); + Assert.True(DateTimeOffset.TryParse(body.GetProperty("periodStart").GetString(), out _)); + }); + } + + [Fact] + public async Task GetLimit_ForUnknownTenant_Returns404() + { + await RunAsync( + async (baseAddress, _, _, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage response = await operatorClient.GetAsync(LimitUrl(baseAddress, Guid.NewGuid())); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(TenantNotFoundDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task PatchLimit_ResetsFlags_UpdatesBudgetAndWritesAudit() + { + var auditStore = new FakeAuditLogStore(); + const long newBudget = 20_000_000; + + await RunAsync( + async (baseAddress, operatorStore, _, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage patch = await PatchJsonAsync( + operatorClient, LimitUrl(baseAddress, FirstTenantId), new { budget = newBudget }); + Assert.Equal(HttpStatusCode.OK, patch.StatusCode); + JsonElement body = await ReadJsonAsync(patch); + Assert.Equal(newBudget, body.GetProperty("budget").GetInt64()); + Assert.Equal(40, body.GetProperty("percent").GetInt32()); // 8 000 000 / 20 000 000 + Assert.False(body.GetProperty("warned80").GetBoolean()); + Assert.False(body.GetProperty("notifiedExhausted").GetBoolean()); + + // Повторное чтение: строка реально изменена (сброс флагов виден и на GET). + using HttpResponseMessage get = await operatorClient.GetAsync(LimitUrl(baseAddress, FirstTenantId)); + JsonElement stored = await ReadJsonAsync(get); + Assert.Equal(newBudget, stored.GetProperty("budget").GetInt64()); + Assert.False(stored.GetProperty("warned80").GetBoolean()); + Assert.False(stored.GetProperty("notifiedExhausted").GetBoolean()); + + // Аудит tenant_limit_changed: актор-оператор, детали старый/новый бюджет. + AuditRecordDto audit = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.TenantLimitChanged); + Assert.Equal(AuditActorTypes.Operator, audit.ActorType); + Assert.Equal(operatorStore.Operators.Single().Id, audit.ActorId); + Assert.Equal(FirstTenantId, audit.TenantId); + Assert.NotNull(audit.DetailJson); + using (var detail = JsonDocument.Parse(audit.DetailJson!)) + { + Assert.Equal(DefaultBudgetTokens, detail.RootElement.GetProperty("oldBudget").GetInt64()); + Assert.Equal(TenantLimitPeriods.Month, detail.RootElement.GetProperty("oldPeriod").GetString()); + Assert.Equal(newBudget, detail.RootElement.GetProperty("budgetTokens").GetInt64()); + Assert.Equal(TenantLimitPeriods.Month, detail.RootElement.GetProperty("period").GetString()); + } + }, + auditStore); + } + + [Fact] + public async Task PatchLimit_OnlyPeriod_KeepsBudgetAndWritesAudit() + { + var auditStore = new FakeAuditLogStore(); + + await RunAsync( + async (baseAddress, operatorStore, _, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage patch = await PatchJsonAsync( + operatorClient, LimitUrl(baseAddress, FirstTenantId), new { period = TenantLimitPeriods.Day }); + Assert.Equal(HttpStatusCode.OK, patch.StatusCode); + JsonElement body = await ReadJsonAsync(patch); + Assert.Equal(DefaultBudgetTokens, body.GetProperty("budget").GetInt64()); + Assert.Equal(TenantLimitPeriods.Day, body.GetProperty("period").GetString()); + + AuditRecordDto audit = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.TenantLimitChanged); + Assert.Equal(operatorStore.Operators.Single().Id, audit.ActorId); + Assert.NotNull(audit.DetailJson); + using (var detail = JsonDocument.Parse(audit.DetailJson!)) + { + Assert.Equal(DefaultBudgetTokens, detail.RootElement.GetProperty("budgetTokens").GetInt64()); + Assert.Equal(TenantLimitPeriods.Day, detail.RootElement.GetProperty("period").GetString()); + } + }, + auditStore); + } + + [Fact] + public async Task PatchLimit_WithSameValues_IsIdempotent_WithoutNewAudit() + { + var auditStore = new FakeAuditLogStore(); + const long budget = 25_000_000; + + await RunAsync( + async (baseAddress, _, _, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage firstPatch = await PatchJsonAsync( + operatorClient, LimitUrl(baseAddress, FirstTenantId), new { budget }); + Assert.Equal(HttpStatusCode.OK, firstPatch.StatusCode); + + using HttpResponseMessage repeatedPatch = await PatchJsonAsync( + operatorClient, LimitUrl(baseAddress, FirstTenantId), new { budget }); + Assert.Equal(HttpStatusCode.OK, repeatedPatch.StatusCode); + + // Реальное изменение — ровно одно; повторный PATCH с теми же значениями аудит не дублирует. + Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.TenantLimitChanged); + }, + auditStore); + } + + [Fact] + public async Task PatchLimit_InvalidBodies_Return400() + { + await RunAsync( + async (baseAddress, _, _, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + string url = LimitUrl(baseAddress, FirstTenantId); + + using HttpResponseMessage empty = await PatchJsonAsync(operatorClient, url, new { }); + Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode); + Assert.Equal(EmptyUpdateDetail, (await ReadJsonAsync(empty)).GetProperty("detail").GetString()); + + using HttpResponseMessage negative = await PatchJsonAsync(operatorClient, url, new { budget = -1 }); + Assert.Equal(HttpStatusCode.BadRequest, negative.StatusCode); + Assert.Equal(NegativeBudgetDetail, (await ReadJsonAsync(negative)).GetProperty("detail").GetString()); + + using HttpResponseMessage badPeriod = await PatchJsonAsync(operatorClient, url, new { period = "week" }); + Assert.Equal(HttpStatusCode.BadRequest, badPeriod.StatusCode); + Assert.Equal(InvalidPeriodDetail, (await ReadJsonAsync(badPeriod)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task PatchLimit_ForUnknownTenant_Returns404() + { + await RunAsync( + async (baseAddress, _, _, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage patch = await PatchJsonAsync( + operatorClient, LimitUrl(baseAddress, Guid.NewGuid()), new { budget = 5_000 }); + Assert.Equal(HttpStatusCode.NotFound, patch.StatusCode); + Assert.Equal(TenantNotFoundDetail, (await ReadJsonAsync(patch)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task PatchLimit_ZeroBudget_AllowsAndReturnsPercent100() + { + await RunAsync( + async (baseAddress, _, _, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + // Бюджет 0 — «ИИ запрещён» (Ruling 3): допустимое значение, расход 8 000 000 виден как 100%. + using HttpResponseMessage patch = await PatchJsonAsync( + operatorClient, LimitUrl(baseAddress, FirstTenantId), new { budget = 0 }); + Assert.Equal(HttpStatusCode.OK, patch.StatusCode); + JsonElement body = await ReadJsonAsync(patch); + Assert.Equal(0, body.GetProperty("budget").GetInt64()); + Assert.Equal(100, body.GetProperty("percent").GetInt32()); + Assert.False(body.GetProperty("allowed").GetBoolean()); + }); + } + + // ─── Хелперы ───────────────────────────────────────────────────────── + + // URL лимита тенанта (GET/PATCH /api/operator/tenants/{id}/limit). + private static string LimitUrl(string baseAddress, Guid tenantId) => + $"{baseAddress}/api/operator/tenants/{tenantId}/limit"; + + // Прогоняет сценарий на хосте с двумя активными тенантами и предзаполненными строками лимитов. + private static Task RunAsync( + Func scenario, + FakeAuditLogStore? auditStore = null) + { + // Первый тенант: расход 80% месячного бюджета (8 000 000 / 10 000 000) + флаг Warned80; + // второй: небольшой бюджет без расхода. Дата начала периода — текущая (reset не срабатывает). + var tenantStore = new FakeTenantStore( + new TenantRecordDto(FirstTenantId, FirstTenantName, TenantStatuses.Active, DateTimeOffset.UtcNow), + new TenantRecordDto(SecondTenantId, SecondTenantName, TenantStatuses.Active, DateTimeOffset.UtcNow)); + var limitStore = new FakeTenantLimitStore(); + limitStore.Preload(FirstTenantId, DefaultBudgetTokens, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 8_000_000, warned80: true); + limitStore.Preload(SecondTenantId, 100_000, TenantLimitPeriods.Month, DateTimeOffset.UtcNow, 0); + return OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + new FakeAuthStore(), + scenario, + auditStore, + tenantStore: tenantStore, + limitStore: limitStore); + } + + // Элемент сводки по идентификатору тенанта. + private static JsonElement FindItem(JsonElement items, Guid tenantId) + { + foreach (JsonElement item in items.EnumerateArray()) + { + if (item.GetProperty("tenantId").GetGuid() == tenantId) + { + return item; + } + } + + throw new Xunit.Sdk.XunitException($"Элемент сводки тенанта {tenantId} не найден"); + } + + // Логинит оператора (ожидается 200). + private static async Task LoginOperatorAsync(HttpClient client, string baseAddress) + { + using HttpResponseMessage login = await PostJsonAsync( + client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword }); + Assert.Equal(HttpStatusCode.OK, login.StatusCode); + } + + // Фейк-хранилище оператора с активным оператором operator/operator. + private static FakeOperatorAuthStore NewOperatorStore() + { + var passwordHasher = new FakePasswordHasher(); + var store = new FakeOperatorAuthStore(); + store.AddOperator(new StoredOperatorDto( + Guid.NewGuid(), + OperatorLogin, + Status: "active", + PasswordHash: passwordHasher.Hash(OperatorPassword))); + return store; + } + + // HTTP-клиент с собственным CookieContainer (изоляция сценариев кук). + private static HttpClient CreateClient(string baseAddress) => + new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) + { + BaseAddress = new Uri(baseAddress), + }; + + // POST JSON-тела и возврат ответа. + private static async Task PostJsonAsync(HttpClient client, string url, object body) + { + string json = JsonSerializer.Serialize(body); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + return await client.PostAsync(url, content); + } + + // PATCH JSON-тела и возврат ответа. + private static async Task PatchJsonAsync(HttpClient client, string url, object body) + { + string json = JsonSerializer.Serialize(body); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using var request = new HttpRequestMessage(HttpMethod.Patch, url) { Content = content }; + return await client.SendAsync(request); + } + + // Читает тело ответа как JSON-документ (корень, отвязанный от документа). + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + await using Stream stream = await response.Content.ReadAsStreamAsync(); + using var document = await JsonDocument.ParseAsync(stream); + return document.RootElement.Clone(); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/OperatorMaintenanceEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/OperatorMaintenanceEndpointsHttpTests.cs index b8b99aa..549df91 100644 --- a/src/core/tests/Deal.Tests.Unit/OperatorMaintenanceEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/OperatorMaintenanceEndpointsHttpTests.cs @@ -1,112 +1,115 @@ -using System.Net; -using System.Text; -using System.Text.Json; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Tests.Unit; - -/// -/// HTTP-тесты операторской maintenance-ручки пакетной миграции схем тенантов -/// (этап 12, пакет C): POST /api/operator/maintenance/tenants/migrate. -/// -/// -/// Прогон на in-process Kestrel (OperatorAuthHttpHost): реальный TenantSchemaMigrationService поверх -/// фейковых реестра () и провижинера (). -/// Реальный провижининг схем (Postgres) — ⚠ Manual; здесь проверяются авторизация и форма сводки. -/// -public sealed class OperatorMaintenanceEndpointsHttpTests -{ - private const string OperatorLogin = "operator"; - private const string OperatorPassword = "operator"; - - // Путь ручки пакетной миграции схем. - private const string MigratePath = "/api/operator/maintenance/tenants/migrate"; - - [Fact] - public async Task Migrate_WithoutOperatorSession_Returns401() - { - await RunAsync( - new FakeTenantStore(), - async (baseAddress, _, _, _, _, _) => - { - HttpClient client = CreateClient(baseAddress); - using HttpResponseMessage response = await client.PostAsync($"{baseAddress}{MigratePath}", content: null); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - Assert.Equal("Требуется вход оператора", (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task Migrate_WithOperatorSession_ProvisionsAllTenantSchemas() - { - Guid tenantA = Guid.Parse("11111111-1111-1111-1111-111111111111"); - Guid tenantB = Guid.Parse("22222222-2222-2222-2222-222222222222"); - var tenantStore = new FakeTenantStore( - new TenantRecordDto(tenantA, "A", TenantStatuses.Active, DateTimeOffset.UtcNow), - new TenantRecordDto(tenantB, "B", TenantStatuses.Active, DateTimeOffset.UtcNow)); - - await RunAsync( - tenantStore, - async (baseAddress, _, _, _, _, _) => - { - HttpClient client = CreateClient(baseAddress); - await LoginOperatorAsync(client, baseAddress); - - using HttpResponseMessage response = await client.PostAsync($"{baseAddress}{MigratePath}", content: null); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - JsonElement body = await ReadJsonAsync(response); - - Assert.True(body.GetProperty("ok").GetBoolean()); - Assert.Equal(2, body.GetProperty("total").GetInt32()); - Assert.Equal(2, body.GetProperty("migrated").GetInt32()); - Assert.Equal(0, body.GetProperty("failed").GetInt32()); - Assert.Empty(body.GetProperty("failedSchemas").EnumerateArray()); - Assert.True(body.GetProperty("durationMs").GetInt64() >= 0); - }); - } - - // Логинит оператора (ожидается 200). - private static async Task LoginOperatorAsync(HttpClient client, string baseAddress) - { - string json = JsonSerializer.Serialize(new { login = OperatorLogin, password = OperatorPassword }); - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - using HttpResponseMessage login = await client.PostAsync($"{baseAddress}/api/operator/auth/login", content); - Assert.Equal(HttpStatusCode.OK, login.StatusCode); - } - - // Прогоняет сценарий на хосте с активным оператором operator/operator и заданным реестром тенантов. - private static Task RunAsync( - FakeTenantStore tenantStore, - Func scenario) => - OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario, tenantStore: tenantStore); - - // Фейк-хранилище оператора с активным оператором operator/operator. - private static FakeOperatorAuthStore NewOperatorStore() - { - var passwordHasher = new FakePasswordHasher(); - var store = new FakeOperatorAuthStore(); - store.AddOperator(new StoredOperatorDto( - Guid.NewGuid(), - OperatorLogin, - Status: "active", - PasswordHash: passwordHasher.Hash(OperatorPassword))); - return store; - } - - // HTTP-клиент с собственным CookieContainer (изоляция сценариев кук). - private static HttpClient CreateClient(string baseAddress) => - new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) - { - BaseAddress = new Uri(baseAddress), - }; - - // Читает тело ответа как JSON-документ (корень, отвязанный от документа). - private static async Task ReadJsonAsync(HttpResponseMessage response) - { - await using Stream stream = await response.Content.ReadAsStreamAsync(); - using var document = await JsonDocument.ParseAsync(stream); - return document.RootElement.Clone(); - } -} +using System.Net; +using System.Text; +using System.Text.Json; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Tests.Unit; + +/// +/// HTTP-тесты операторской maintenance-ручки пакетной миграции схем тенантов +/// (этап 12, пакет C): POST /api/operator/maintenance/tenants/migrate. +/// +/// +/// Прогон на in-process Kestrel (OperatorAuthHttpHost): реальный TenantSchemaMigrationService поверх +/// фейковых реестра () и провижинера (). +/// Реальный провижининг схем (Postgres) — ⚠ Manual; здесь проверяются авторизация и форма сводки. +/// +public sealed class OperatorMaintenanceEndpointsHttpTests +{ + private const string OperatorLogin = "operator"; + private const string OperatorPassword = "operator"; + + // Путь ручки пакетной миграции схем. + private const string MigratePath = "/api/operator/maintenance/tenants/migrate"; + + [Fact] + public async Task Migrate_WithoutOperatorSession_Returns401() + { + await RunAsync( + new FakeTenantStore(), + async (baseAddress, _, _, _, _, _) => + { + HttpClient client = CreateClient(baseAddress); + using HttpResponseMessage response = await client.PostAsync($"{baseAddress}{MigratePath}", content: null); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal("Требуется вход оператора", (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task Migrate_WithOperatorSession_ProvisionsAllTenantSchemas() + { + Guid tenantA = Guid.Parse("11111111-1111-1111-1111-111111111111"); + Guid tenantB = Guid.Parse("22222222-2222-2222-2222-222222222222"); + var tenantStore = new FakeTenantStore( + new TenantRecordDto(tenantA, "A", TenantStatuses.Active, DateTimeOffset.UtcNow), + new TenantRecordDto(tenantB, "B", TenantStatuses.Active, DateTimeOffset.UtcNow)); + + await RunAsync( + tenantStore, + async (baseAddress, _, _, _, _, _) => + { + HttpClient client = CreateClient(baseAddress); + await LoginOperatorAsync(client, baseAddress); + + using HttpResponseMessage response = await client.PostAsync($"{baseAddress}{MigratePath}", content: null); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + JsonElement body = await ReadJsonAsync(response); + + Assert.True(body.GetProperty("ok").GetBoolean()); + Assert.Equal(2, body.GetProperty("total").GetInt32()); + Assert.Equal(2, body.GetProperty("migrated").GetInt32()); + Assert.Equal(0, body.GetProperty("failed").GetInt32()); + Assert.Empty(body.GetProperty("failedSchemas").EnumerateArray()); + Assert.True(body.GetProperty("durationMs").GetInt64() >= 0); + }); + } + + // Логинит оператора (ожидается 200). + private static async Task LoginOperatorAsync(HttpClient client, string baseAddress) + { + string json = JsonSerializer.Serialize(new { login = OperatorLogin, password = OperatorPassword }); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + using HttpResponseMessage login = await client.PostAsync($"{baseAddress}/api/operator/auth/login", content); + Assert.Equal(HttpStatusCode.OK, login.StatusCode); + } + + // Прогоняет сценарий на хосте с активным оператором operator/operator и заданным реестром тенантов. + private static Task RunAsync( + FakeTenantStore tenantStore, + Func scenario) => + OperatorAuthHttpHost.RunAsync(NewOperatorStore(), new FakeAuthStore(), scenario, tenantStore: tenantStore); + + // Фейк-хранилище оператора с активным оператором operator/operator. + private static FakeOperatorAuthStore NewOperatorStore() + { + var passwordHasher = new FakePasswordHasher(); + var store = new FakeOperatorAuthStore(); + store.AddOperator(new StoredOperatorDto( + Guid.NewGuid(), + OperatorLogin, + Status: "active", + PasswordHash: passwordHasher.Hash(OperatorPassword))); + return store; + } + + // HTTP-клиент с собственным CookieContainer (изоляция сценариев кук). + private static HttpClient CreateClient(string baseAddress) => + new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) + { + BaseAddress = new Uri(baseAddress), + }; + + // Читает тело ответа как JSON-документ (корень, отвязанный от документа). + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + await using Stream stream = await response.Content.ReadAsStreamAsync(); + using var document = await JsonDocument.ParseAsync(stream); + return document.RootElement.Clone(); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/OperatorSettingsEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/OperatorSettingsEndpointsHttpTests.cs index d7bfa0c..bba2a9f 100644 --- a/src/core/tests/Deal.Tests.Unit/OperatorSettingsEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/OperatorSettingsEndpointsHttpTests.cs @@ -1,325 +1,331 @@ -using System.Net; -using System.Text; -using System.Text.Json; -using Deal.Modules.Settings.Application; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Tests.Unit; - -/// -/// HTTP-тесты операторских ручек глобальных настроек Telegram (ТЗ §4.1/§8.1): -/// GET/PUT /api/operator/settings/telegram-keys. -/// -/// -/// Прогон на in-process Kestrel (OperatorAuthHttpHost) с фейками: глобальное KV-хранилище -/// (FakeGlobalSettingsStore), шифр (FakeSecretCipher), аудит (настоящий сервис на фейк-сторе). -/// Проверяются маскирование ответа, шифрование apiHash в хранилище, валидация (api_id/api_hash), -/// 401 без операторской сессии и аудит telegram_keys_changed (без секретов). -/// -public sealed class OperatorSettingsEndpointsHttpTests -{ - private const string OperatorLogin = "operator"; - private const string OperatorPassword = "operator"; - private const string InvalidApiIdDetail = "api_id должен состоять из 5–9 цифр"; - private const string InvalidApiHashDetail = "Укажите непустой api_hash"; - private const string EmptyBodyDetail = "Укажите api_id и api_hash"; - private const string MissingKeysDetail = "Ключи ещё не заданы — укажите и api_id, и api_hash"; - - [Fact] - public async Task TelegramKeys_WithoutOperatorSession_Returns401() - { - await RunAsync( - async (baseAddress, _, _, _, _) => - { - using HttpClient anonymous = CreateClient(baseAddress); - - using HttpResponseMessage get = await anonymous.GetAsync(TgKeysUrl(baseAddress)); - Assert.Equal(HttpStatusCode.Unauthorized, get.StatusCode); - Assert.Equal("Требуется вход оператора", (await ReadJsonAsync(get)).GetProperty("detail").GetString()); - - using HttpResponseMessage put = await PutJsonAsync( - anonymous, TgKeysUrl(baseAddress), new { apiId = "1234567", apiHash = "abcdefghijklmnop" }); - Assert.Equal(HttpStatusCode.Unauthorized, put.StatusCode); - Assert.Equal("Требуется вход оператора", (await ReadJsonAsync(put)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task GetTelegramKeys_NoKeys_ReturnsEmptyMaskedForm() - { - await RunAsync( - async (baseAddress, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage response = await operatorClient.GetAsync(TgKeysUrl(baseAddress)); - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - JsonElement body = await ReadJsonAsync(response); - Assert.Equal(string.Empty, body.GetProperty("apiId").GetString()); - Assert.Equal(string.Empty, body.GetProperty("apiHash").GetString()); - Assert.False(body.GetProperty("keysSet").GetBoolean()); - }); - } - - [Fact] - public async Task PutTelegramKeys_SavesEncryptedKeysMasksResponseAndWritesAudit() - { - var auditStore = new FakeAuditLogStore(); - - await RunAsync( - async (baseAddress, operatorStore, _, globalSettings, audit) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage put = await PutJsonAsync( - operatorClient, TgKeysUrl(baseAddress), new { apiId = "1234567", apiHash = "abcdefghijklmnop" }); - Assert.Equal(HttpStatusCode.OK, put.StatusCode); - JsonElement body = await ReadJsonAsync(put); - Assert.Equal("1234567", body.GetProperty("apiId").GetString()); - Assert.Equal("abcd…mnop", body.GetProperty("apiHash").GetString()); - Assert.True(body.GetProperty("keysSet").GetBoolean()); - - // В хранилище apiHash — только в enc:-форме, открытого секрета нет. - string storedJson = globalSettings.GetStoredJson(GlobalSettingsKeys.TelegramKeys)!; - Assert.Contains("enc:", storedJson); - Assert.DoesNotContain("abcdefghijklmnop", storedJson); - - // Аудит: актор-оператор, детали без секрета. - AuditRecordDto record = Assert.Single(audit.Records, r => r.EventType == AuditEvents.TelegramKeysChanged); - Assert.Equal(AuditActorTypes.Operator, record.ActorType); - Assert.Equal(operatorStore.Operators.Single().Id, record.ActorId); - Assert.Null(record.TenantId); - Assert.NotNull(record.DetailJson); - Assert.DoesNotContain("abcdefghijklmnop", record.DetailJson); - using var detail = JsonDocument.Parse(record.DetailJson!); - Assert.Equal("1234567", detail.RootElement.GetProperty("apiId").GetString()); - Assert.True(detail.RootElement.GetProperty("apiHashSet").GetBoolean()); - }, - auditStore); - } - - [Fact] - public async Task GetTelegramKeys_AfterPut_ReturnsMaskedStoredKeys() - { - await RunAsync( - async (baseAddress, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage put = await PutJsonAsync( - operatorClient, TgKeysUrl(baseAddress), new { apiId = "12345", apiHash = "short" }); - Assert.Equal(HttpStatusCode.OK, put.StatusCode); - - using HttpResponseMessage get = await operatorClient.GetAsync(TgKeysUrl(baseAddress)); - JsonElement body = await ReadJsonAsync(get); - Assert.Equal("12345", body.GetProperty("apiId").GetString()); - Assert.Equal("s…", body.GetProperty("apiHash").GetString()); // короткий секрет полностью замаскирован - Assert.True(body.GetProperty("keysSet").GetBoolean()); - }); - } - - [Fact] - public async Task PutTelegramKeys_InvalidApiId_Returns400() - { - await RunAsync( - async (baseAddress, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage response = await PutJsonAsync( - operatorClient, TgKeysUrl(baseAddress), new { apiId = "abc", apiHash = "abcdefghijklmnop" }); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal(InvalidApiIdDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task PutTelegramKeys_EmptyOrMaskedApiHash_Returns400() - { - await RunAsync( - async (baseAddress, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage empty = await PutJsonAsync( - operatorClient, TgKeysUrl(baseAddress), new { apiId = "1234567", apiHash = "" }); - Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode); - Assert.Equal(InvalidApiHashDetail, (await ReadJsonAsync(empty)).GetProperty("detail").GetString()); - - using HttpResponseMessage masked = await PutJsonAsync( - operatorClient, TgKeysUrl(baseAddress), new { apiId = "1234567", apiHash = "abcd…mnop" }); - Assert.Equal(HttpStatusCode.BadRequest, masked.StatusCode); - Assert.Equal(InvalidApiHashDetail, (await ReadJsonAsync(masked)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task PutTelegramKeys_OnlyApiId_KeepsExistingApiHash() - { - await RunAsync( - async (baseAddress, _, _, globalSettings, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage initial = await PutJsonAsync( - operatorClient, TgKeysUrl(baseAddress), new { apiId = "1234567", apiHash = "abcdefghijklmnop" }); - Assert.Equal(HttpStatusCode.OK, initial.StatusCode); - - // Частичное обновление: только apiId, apiHash сохраняется из текущих ключей. - using HttpResponseMessage put = await PutJsonAsync( - operatorClient, TgKeysUrl(baseAddress), new { apiId = "7654321" }); - Assert.Equal(HttpStatusCode.OK, put.StatusCode); - JsonElement body = await ReadJsonAsync(put); - Assert.Equal("7654321", body.GetProperty("apiId").GetString()); - Assert.Equal("abcd…mnop", body.GetProperty("apiHash").GetString()); - Assert.True(body.GetProperty("keysSet").GetBoolean()); - - // Хранилище всё ещё держит прежний секрет в enc:-форме (не перезаписан пустым). - string storedJson = globalSettings.GetStoredJson(GlobalSettingsKeys.TelegramKeys)!; - Assert.Contains("enc:", storedJson); - Assert.DoesNotContain("abcdefghijklmnop", storedJson); - }); - } - - [Fact] - public async Task PutTelegramKeys_OnlyApiHash_KeepsExistingApiId() - { - await RunAsync( - async (baseAddress, _, _, globalSettings, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage initial = await PutJsonAsync( - operatorClient, TgKeysUrl(baseAddress), new { apiId = "1234567", apiHash = "abcdefghijklmnop" }); - Assert.Equal(HttpStatusCode.OK, initial.StatusCode); - - // Частичное обновление: только apiHash, apiId сохраняется из текущих ключей. - using HttpResponseMessage put = await PutJsonAsync( - operatorClient, TgKeysUrl(baseAddress), new { apiHash = "newsecrethash12" }); - Assert.Equal(HttpStatusCode.OK, put.StatusCode); - JsonElement body = await ReadJsonAsync(put); - Assert.Equal("1234567", body.GetProperty("apiId").GetString()); - Assert.Equal("news…sh12", body.GetProperty("apiHash").GetString()); - Assert.True(body.GetProperty("keysSet").GetBoolean()); - - string storedJson = globalSettings.GetStoredJson(GlobalSettingsKeys.TelegramKeys)!; - Assert.Contains("enc:", storedJson); - Assert.DoesNotContain("newsecrethash12", storedJson); - }); - } - - [Fact] - public async Task PutTelegramKeys_PartialWithoutExistingKeys_Returns400() - { - await RunAsync( - async (baseAddress, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - // Ключей нет: одно лишь apiId (без apiHash) неполно — нужны оба. - using HttpResponseMessage idOnly = await PutJsonAsync( - operatorClient, TgKeysUrl(baseAddress), new { apiId = "1234567" }); - Assert.Equal(HttpStatusCode.BadRequest, idOnly.StatusCode); - Assert.Equal(MissingKeysDetail, (await ReadJsonAsync(idOnly)).GetProperty("detail").GetString()); - - // Ключей нет: одно лишь apiHash (без apiId) неполно. - using HttpResponseMessage hashOnly = await PutJsonAsync( - operatorClient, TgKeysUrl(baseAddress), new { apiHash = "abcdefghijklmnop" }); - Assert.Equal(HttpStatusCode.BadRequest, hashOnly.StatusCode); - Assert.Equal(MissingKeysDetail, (await ReadJsonAsync(hashOnly)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task PutTelegramKeys_NoFields_Returns400() - { - await RunAsync( - async (baseAddress, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage response = await PutJsonAsync( - operatorClient, TgKeysUrl(baseAddress), new { }); - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal(EmptyBodyDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - // ─── Хелперы ───────────────────────────────────────────────────────── - - // URL глобальных ключей Telegram (GET/PUT /api/operator/settings/telegram-keys). - private static string TgKeysUrl(string baseAddress) => - $"{baseAddress}/api/operator/settings/telegram-keys"; - - // Прогоняет сценарий на хосте с фейком глобального хранилища и аудита. - private static Task RunAsync( - Func scenario, - FakeAuditLogStore? auditStore = null) => - OperatorAuthHttpHost.RunWithGlobalSettingsAsync( - NewOperatorStore(), - new FakeAuthStore(), - scenario, - auditStore); - - // Логинит оператора (ожидается 200). - private static async Task LoginOperatorAsync(HttpClient client, string baseAddress) - { - using HttpResponseMessage login = await PostJsonAsync( - client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword }); - Assert.Equal(HttpStatusCode.OK, login.StatusCode); - } - - // Фейк-хранилище оператора с активным оператором operator/operator. - private static FakeOperatorAuthStore NewOperatorStore() - { - var passwordHasher = new FakePasswordHasher(); - var store = new FakeOperatorAuthStore(); - store.AddOperator(new StoredOperatorDto( - Guid.NewGuid(), - OperatorLogin, - Status: "active", - PasswordHash: passwordHasher.Hash(OperatorPassword))); - return store; - } - - // HTTP-клиент с собственным CookieContainer (изоляция сценариев кук). - private static HttpClient CreateClient(string baseAddress) => - new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) - { - BaseAddress = new Uri(baseAddress), - }; - - // POST JSON-тела и возврат ответа. - private static async Task PostJsonAsync(HttpClient client, string url, object body) - { - string json = JsonSerializer.Serialize(body); - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - return await client.PostAsync(url, content); - } - - // PUT JSON-тела и возврат ответа. - private static async Task PutJsonAsync(HttpClient client, string url, object body) - { - string json = JsonSerializer.Serialize(body); - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - return await client.PutAsync(url, content); - } - - // Читает тело ответа как JSON-документ (корень, отвязанный от документа). - private static async Task ReadJsonAsync(HttpResponseMessage response) - { - await using Stream stream = await response.Content.ReadAsStreamAsync(); - using var document = await JsonDocument.ParseAsync(stream); - return document.RootElement.Clone(); - } -} +using System.Net; +using System.Text; +using System.Text.Json; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Tests.Unit; + +/// +/// HTTP-тесты операторских ручек глобальных настроек Telegram (ТЗ §4.1/§8.1): +/// GET/PUT /api/operator/settings/telegram-keys. +/// +/// +/// Прогон на in-process Kestrel (OperatorAuthHttpHost) с фейками: глобальное KV-хранилище +/// (FakeGlobalSettingsStore), шифр (FakeSecretCipher), аудит (настоящий сервис на фейк-сторе). +/// Проверяются маскирование ответа, шифрование apiHash в хранилище, валидация (api_id/api_hash), +/// 401 без операторской сессии и аудит telegram_keys_changed (без секретов). +/// +public sealed class OperatorSettingsEndpointsHttpTests +{ + private const string OperatorLogin = "operator"; + private const string OperatorPassword = "operator"; + private const string InvalidApiIdDetail = "api_id должен состоять из 5–9 цифр"; + private const string InvalidApiHashDetail = "Укажите непустой api_hash"; + private const string EmptyBodyDetail = "Укажите api_id и api_hash"; + private const string MissingKeysDetail = "Ключи ещё не заданы — укажите и api_id, и api_hash"; + + [Fact] + public async Task TelegramKeys_WithoutOperatorSession_Returns401() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + using HttpClient anonymous = CreateClient(baseAddress); + + using HttpResponseMessage get = await anonymous.GetAsync(TgKeysUrl(baseAddress)); + Assert.Equal(HttpStatusCode.Unauthorized, get.StatusCode); + Assert.Equal("Требуется вход оператора", (await ReadJsonAsync(get)).GetProperty("detail").GetString()); + + using HttpResponseMessage put = await PutJsonAsync( + anonymous, TgKeysUrl(baseAddress), new { apiId = "1234567", apiHash = "abcdefghijklmnop" }); + Assert.Equal(HttpStatusCode.Unauthorized, put.StatusCode); + Assert.Equal("Требуется вход оператора", (await ReadJsonAsync(put)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task GetTelegramKeys_NoKeys_ReturnsEmptyMaskedForm() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage response = await operatorClient.GetAsync(TgKeysUrl(baseAddress)); + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + JsonElement body = await ReadJsonAsync(response); + Assert.Equal(string.Empty, body.GetProperty("apiId").GetString()); + Assert.Equal(string.Empty, body.GetProperty("apiHash").GetString()); + Assert.False(body.GetProperty("keysSet").GetBoolean()); + }); + } + + [Fact] + public async Task PutTelegramKeys_SavesEncryptedKeysMasksResponseAndWritesAudit() + { + var auditStore = new FakeAuditLogStore(); + + await RunAsync( + async (baseAddress, operatorStore, _, globalSettings, audit) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage put = await PutJsonAsync( + operatorClient, TgKeysUrl(baseAddress), new { apiId = "1234567", apiHash = "abcdefghijklmnop" }); + Assert.Equal(HttpStatusCode.OK, put.StatusCode); + JsonElement body = await ReadJsonAsync(put); + Assert.Equal("1234567", body.GetProperty("apiId").GetString()); + Assert.Equal("abcd…mnop", body.GetProperty("apiHash").GetString()); + Assert.True(body.GetProperty("keysSet").GetBoolean()); + + // В хранилище apiHash — только в enc:-форме, открытого секрета нет. + string storedJson = globalSettings.GetStoredJson(GlobalSettingsKeys.TelegramKeys)!; + Assert.Contains("enc:", storedJson); + Assert.DoesNotContain("abcdefghijklmnop", storedJson); + + // Аудит: актор-оператор, детали без секрета. + AuditRecordDto record = Assert.Single(audit.Records, r => r.EventType == AuditEvents.TelegramKeysChanged); + Assert.Equal(AuditActorTypes.Operator, record.ActorType); + Assert.Equal(operatorStore.Operators.Single().Id, record.ActorId); + Assert.Null(record.TenantId); + Assert.NotNull(record.DetailJson); + Assert.DoesNotContain("abcdefghijklmnop", record.DetailJson); + using var detail = JsonDocument.Parse(record.DetailJson!); + Assert.Equal("1234567", detail.RootElement.GetProperty("apiId").GetString()); + Assert.True(detail.RootElement.GetProperty("apiHashSet").GetBoolean()); + }, + auditStore); + } + + [Fact] + public async Task GetTelegramKeys_AfterPut_ReturnsMaskedStoredKeys() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage put = await PutJsonAsync( + operatorClient, TgKeysUrl(baseAddress), new { apiId = "12345", apiHash = "short" }); + Assert.Equal(HttpStatusCode.OK, put.StatusCode); + + using HttpResponseMessage get = await operatorClient.GetAsync(TgKeysUrl(baseAddress)); + JsonElement body = await ReadJsonAsync(get); + Assert.Equal("12345", body.GetProperty("apiId").GetString()); + Assert.Equal("s…", body.GetProperty("apiHash").GetString()); // короткий секрет полностью замаскирован + Assert.True(body.GetProperty("keysSet").GetBoolean()); + }); + } + + [Fact] + public async Task PutTelegramKeys_InvalidApiId_Returns400() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage response = await PutJsonAsync( + operatorClient, TgKeysUrl(baseAddress), new { apiId = "abc", apiHash = "abcdefghijklmnop" }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(InvalidApiIdDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task PutTelegramKeys_EmptyOrMaskedApiHash_Returns400() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage empty = await PutJsonAsync( + operatorClient, TgKeysUrl(baseAddress), new { apiId = "1234567", apiHash = "" }); + Assert.Equal(HttpStatusCode.BadRequest, empty.StatusCode); + Assert.Equal(InvalidApiHashDetail, (await ReadJsonAsync(empty)).GetProperty("detail").GetString()); + + using HttpResponseMessage masked = await PutJsonAsync( + operatorClient, TgKeysUrl(baseAddress), new { apiId = "1234567", apiHash = "abcd…mnop" }); + Assert.Equal(HttpStatusCode.BadRequest, masked.StatusCode); + Assert.Equal(InvalidApiHashDetail, (await ReadJsonAsync(masked)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task PutTelegramKeys_OnlyApiId_KeepsExistingApiHash() + { + await RunAsync( + async (baseAddress, _, _, globalSettings, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage initial = await PutJsonAsync( + operatorClient, TgKeysUrl(baseAddress), new { apiId = "1234567", apiHash = "abcdefghijklmnop" }); + Assert.Equal(HttpStatusCode.OK, initial.StatusCode); + + // Частичное обновление: только apiId, apiHash сохраняется из текущих ключей. + using HttpResponseMessage put = await PutJsonAsync( + operatorClient, TgKeysUrl(baseAddress), new { apiId = "7654321" }); + Assert.Equal(HttpStatusCode.OK, put.StatusCode); + JsonElement body = await ReadJsonAsync(put); + Assert.Equal("7654321", body.GetProperty("apiId").GetString()); + Assert.Equal("abcd…mnop", body.GetProperty("apiHash").GetString()); + Assert.True(body.GetProperty("keysSet").GetBoolean()); + + // Хранилище всё ещё держит прежний секрет в enc:-форме (не перезаписан пустым). + string storedJson = globalSettings.GetStoredJson(GlobalSettingsKeys.TelegramKeys)!; + Assert.Contains("enc:", storedJson); + Assert.DoesNotContain("abcdefghijklmnop", storedJson); + }); + } + + [Fact] + public async Task PutTelegramKeys_OnlyApiHash_KeepsExistingApiId() + { + await RunAsync( + async (baseAddress, _, _, globalSettings, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage initial = await PutJsonAsync( + operatorClient, TgKeysUrl(baseAddress), new { apiId = "1234567", apiHash = "abcdefghijklmnop" }); + Assert.Equal(HttpStatusCode.OK, initial.StatusCode); + + // Частичное обновление: только apiHash, apiId сохраняется из текущих ключей. + using HttpResponseMessage put = await PutJsonAsync( + operatorClient, TgKeysUrl(baseAddress), new { apiHash = "newsecrethash12" }); + Assert.Equal(HttpStatusCode.OK, put.StatusCode); + JsonElement body = await ReadJsonAsync(put); + Assert.Equal("1234567", body.GetProperty("apiId").GetString()); + Assert.Equal("news…sh12", body.GetProperty("apiHash").GetString()); + Assert.True(body.GetProperty("keysSet").GetBoolean()); + + string storedJson = globalSettings.GetStoredJson(GlobalSettingsKeys.TelegramKeys)!; + Assert.Contains("enc:", storedJson); + Assert.DoesNotContain("newsecrethash12", storedJson); + }); + } + + [Fact] + public async Task PutTelegramKeys_PartialWithoutExistingKeys_Returns400() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + // Ключей нет: одно лишь apiId (без apiHash) неполно — нужны оба. + using HttpResponseMessage idOnly = await PutJsonAsync( + operatorClient, TgKeysUrl(baseAddress), new { apiId = "1234567" }); + Assert.Equal(HttpStatusCode.BadRequest, idOnly.StatusCode); + Assert.Equal(MissingKeysDetail, (await ReadJsonAsync(idOnly)).GetProperty("detail").GetString()); + + // Ключей нет: одно лишь apiHash (без apiId) неполно. + using HttpResponseMessage hashOnly = await PutJsonAsync( + operatorClient, TgKeysUrl(baseAddress), new { apiHash = "abcdefghijklmnop" }); + Assert.Equal(HttpStatusCode.BadRequest, hashOnly.StatusCode); + Assert.Equal(MissingKeysDetail, (await ReadJsonAsync(hashOnly)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task PutTelegramKeys_NoFields_Returns400() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage response = await PutJsonAsync( + operatorClient, TgKeysUrl(baseAddress), new { }); + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(EmptyBodyDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + // ─── Хелперы ───────────────────────────────────────────────────────── + + // URL глобальных ключей Telegram (GET/PUT /api/operator/settings/telegram-keys). + private static string TgKeysUrl(string baseAddress) => + $"{baseAddress}/api/operator/settings/telegram-keys"; + + // Прогоняет сценарий на хосте с фейком глобального хранилища и аудита. + private static Task RunAsync( + Func scenario, + FakeAuditLogStore? auditStore = null) => + OperatorAuthHttpHost.RunWithGlobalSettingsAsync( + NewOperatorStore(), + new FakeAuthStore(), + scenario, + auditStore); + + // Логинит оператора (ожидается 200). + private static async Task LoginOperatorAsync(HttpClient client, string baseAddress) + { + using HttpResponseMessage login = await PostJsonAsync( + client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword }); + Assert.Equal(HttpStatusCode.OK, login.StatusCode); + } + + // Фейк-хранилище оператора с активным оператором operator/operator. + private static FakeOperatorAuthStore NewOperatorStore() + { + var passwordHasher = new FakePasswordHasher(); + var store = new FakeOperatorAuthStore(); + store.AddOperator(new StoredOperatorDto( + Guid.NewGuid(), + OperatorLogin, + Status: "active", + PasswordHash: passwordHasher.Hash(OperatorPassword))); + return store; + } + + // HTTP-клиент с собственным CookieContainer (изоляция сценариев кук). + private static HttpClient CreateClient(string baseAddress) => + new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) + { + BaseAddress = new Uri(baseAddress), + }; + + // POST JSON-тела и возврат ответа. + private static async Task PostJsonAsync(HttpClient client, string url, object body) + { + string json = JsonSerializer.Serialize(body); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + return await client.PostAsync(url, content); + } + + // PUT JSON-тела и возврат ответа. + private static async Task PutJsonAsync(HttpClient client, string url, object body) + { + string json = JsonSerializer.Serialize(body); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + return await client.PutAsync(url, content); + } + + // Читает тело ответа как JSON-документ (корень, отвязанный от документа). + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + await using Stream stream = await response.Content.ReadAsStreamAsync(); + using var document = await JsonDocument.ParseAsync(stream); + return document.RootElement.Clone(); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/OperatorTenantsEndpointsHttpTests.cs b/src/core/tests/Deal.Tests.Unit/OperatorTenantsEndpointsHttpTests.cs index 66c8ee0..2d0ae3c 100644 --- a/src/core/tests/Deal.Tests.Unit/OperatorTenantsEndpointsHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/OperatorTenantsEndpointsHttpTests.cs @@ -1,619 +1,622 @@ -using System.Net; -using System.Text; -using System.Text.Json; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Tests.Unit; - -/// -/// HTTP-тесты операторских ручек тенантов (план Task 7, Ruling 1/4/10/11): эквивалент curl-минимума -/// list → suspend → login заблокирован → unsuspend → login ok и impersonate → deal_session работает на /me. -/// -/// -/// Прогон на in-process Kestrel (OperatorAuthHttpHost) с фейк-хранилищами: реестр тенантов, пользователи и -/// аудит — настоящие сервисы модуля. Проверяются формы ответов, 401 без операторской сессии и аудит -/// (tenant_status_changed, tenant_login_failed с tenantId для suspended-тенанта, impersonation_started/stopped). -/// Живая curl-приёмка на :5080 — ⚠ Manual (нужен Postgres). -/// -public sealed class OperatorTenantsEndpointsHttpTests -{ - private const string OperatorLogin = "operator"; - private const string OperatorPassword = "operator"; - private const string TenantName = "Тенант для проверки"; - private const string TenantUserLogin = "user@example.com"; - private const string TenantUserPassword = "user-password"; - private const string TenantCookieName = "deal_session"; - private const string TenantNotFoundDetail = "Тенант не найден"; - private const string UserNotFoundInTenantDetail = "Пользователь не найден в тенанте"; - private const string TenantHasNoUsersDetail = "В тенанте нет пользователей для входа"; - private const string TenantSuspendedLoginDetail = "Учётная запись приостановлена. Обратитесь к оператору"; - private const string TenantNameRequiredDetail = "Имя тенанта обязательно"; - private const string EmailTakenDetail = "Этот email уже зарегистрирован"; - private const string NewTenantName = "Новый тенант оператора"; - - private static readonly Guid TenantId = Guid.NewGuid(); - - [Fact] - public async Task List_WithoutOperatorSession_Returns401() - { - await RunAsync( - async (baseAddress, _, _, _, _) => - { - using HttpResponseMessage response = await CreateClient(baseAddress).GetAsync($"{baseAddress}/api/operator/tenants"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - Assert.Equal("Требуется вход оператора", (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task Detail_WithoutOperatorSession_Returns401() - { - await RunAsync( - async (baseAddress, _, _, _, _) => - { - using HttpResponseMessage response = await CreateClient(baseAddress).GetAsync($"{baseAddress}/api/operator/tenants/{TenantId}"); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - }); - } - - [Fact] - public async Task Suspend_WithoutOperatorSession_Returns401() - { - await RunAsync( - async (baseAddress, _, _, _, _) => - { - using HttpResponseMessage response = await CreateClient(baseAddress).PostAsync( - $"{baseAddress}/api/operator/tenants/{TenantId}/suspend", content: null); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - }); - } - - [Fact] - public async Task Impersonate_WithoutOperatorSession_Returns401() - { - await RunAsync( - async (baseAddress, _, _, _, _) => - { - using HttpResponseMessage response = await CreateClient(baseAddress).PostAsync( - $"{baseAddress}/api/operator/tenants/{TenantId}/impersonate", content: null); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - }); - } - - [Fact] - public async Task Create_WithoutOperatorSession_Returns401() - { - await RunAsync( - async (baseAddress, _, _, _, _) => - { - using HttpResponseMessage response = await PostJsonAsync( - CreateClient(baseAddress), $"{baseAddress}/api/operator/tenants", new { name = NewTenantName }); - - Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); - Assert.Equal("Требуется вход оператора", (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task Create_ReturnsCreatedTenant_AndWritesTenantCreatedAudit() - { - var auditStore = new FakeAuditLogStore(); - - await RunAsync( - async (baseAddress, operatorStore, _, tenantStore, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage create = await PostJsonAsync( - operatorClient, $"{baseAddress}/api/operator/tenants", new { name = NewTenantName }); - Assert.Equal(HttpStatusCode.OK, create.StatusCode); - JsonElement body = await ReadJsonAsync(create); - Guid createdId = body.GetProperty("id").GetGuid(); - Assert.Equal(NewTenantName, body.GetProperty("name").GetString()); - Assert.Equal(TenantStatuses.Active, body.GetProperty("status").GetString()); - Assert.True(DateTimeOffset.TryParse(body.GetProperty("createdAt").GetString(), out _)); - Assert.False(body.TryGetProperty("ownerEmail", out _)); - - // Реестр: новый тенант (провижининг схемы — фейк в хосте; реальный — TenantProvisioningService ⚠ Manual). - TenantRecordDto stored = Assert.Single(tenantStore.Tenants, t => t.Id == createdId); - Assert.Equal(NewTenantName, stored.Name); - Assert.Equal(TenantStatuses.Active, stored.Status); - - // Аудит tenant_created: актор-оператор, TenantId нового тенанта, детали id+имя. - AuditRecordDto createdAudit = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.TenantCreated); - Assert.Equal(AuditActorTypes.Operator, createdAudit.ActorType); - Assert.Equal(operatorStore.Operators.Single().Id, createdAudit.ActorId); - Assert.Equal(createdId, createdAudit.TenantId); - Assert.NotNull(createdAudit.DetailJson); - using (var detail = JsonDocument.Parse(createdAudit.DetailJson!)) - { - Assert.Equal(createdId, detail.RootElement.GetProperty("tenantId").GetGuid()); - Assert.Equal(NewTenantName, detail.RootElement.GetProperty("name").GetString()); - } - }, - auditStore); - } - - [Fact] - public async Task Create_WithOwnerEmail_ReturnsOneTimePasswordAndCreatesOwner() - { - var auditStore = new FakeAuditLogStore(); - const string ownerEmail = "owner@created.com"; - - await RunAsync( - async (baseAddress, operatorStore, userStore, tenantStore, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage create = await PostJsonAsync( - operatorClient, $"{baseAddress}/api/operator/tenants", new { name = NewTenantName, email = ownerEmail }); - Assert.Equal(HttpStatusCode.OK, create.StatusCode); - JsonElement body = await ReadJsonAsync(create); - Guid createdId = body.GetProperty("id").GetGuid(); - Assert.Equal(ownerEmail, body.GetProperty("ownerEmail").GetString()); - string initialPassword = body.GetProperty("initialPassword").GetString()!; - Assert.NotEmpty(initialPassword); - - // Владелец создан в новом тенанте; raw-пароль в хранилище не хранится (только хэш). - StoredUserDto owner = Assert.Single(userStore.Users, u => u.Login == ownerEmail); - Assert.Equal(createdId, owner.TenantId); - Assert.NotEqual(initialPassword, owner.PasswordHash); - - AuditRecordDto createdAudit = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.TenantCreated); - Assert.Equal(operatorStore.Operators.Single().Id, createdAudit.ActorId); - Assert.Equal(createdId, createdAudit.TenantId); - }, - auditStore); - } - - [Fact] - public async Task Create_WithEmptyName_Returns400() - { - await RunAsync( - async (baseAddress, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage response = await PostJsonAsync( - operatorClient, $"{baseAddress}/api/operator/tenants", new { name = " " }); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal(TenantNameRequiredDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task Create_WithTakenEmail_Returns400() - { - await RunAsync( - async (baseAddress, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - // TenantUserLogin уже зарегистрирован (дефолтный пользователь сценария) — создание владельца с ним нельзя. - using HttpResponseMessage response = await PostJsonAsync( - operatorClient, $"{baseAddress}/api/operator/tenants", new { name = NewTenantName, email = TenantUserLogin }); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal(EmailTakenDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task ListAndDetail_ReturnTenantWithCountersAndUsers() - { - await RunAsync( - async (baseAddress, operatorStore, _, tenantStore, _) => - { - HttpClient client = CreateClient(baseAddress); - await LoginOperatorAsync(client, baseAddress); - - // GET /api/operator/tenants: реестр + счётчик пользователей (поля лимитов — Task 8). - using HttpResponseMessage list = await client.GetAsync($"{baseAddress}/api/operator/tenants"); - Assert.Equal(HttpStatusCode.OK, list.StatusCode); - JsonElement item = (await ReadJsonAsync(list)).GetProperty("items").EnumerateArray().Single(); - Assert.Equal(TenantId, item.GetProperty("id").GetGuid()); - Assert.Equal(TenantName, item.GetProperty("name").GetString()); - Assert.Equal(TenantStatuses.Active, item.GetProperty("status").GetString()); - Assert.Equal(1, item.GetProperty("usersCount").GetInt32()); - - // GET /api/operator/tenants/{id}: детали + пользователи. - using HttpResponseMessage detail = await client.GetAsync($"{baseAddress}/api/operator/tenants/{TenantId}"); - Assert.Equal(HttpStatusCode.OK, detail.StatusCode); - JsonElement detailBody = await ReadJsonAsync(detail); - Assert.Equal(TenantName, detailBody.GetProperty("name").GetString()); - JsonElement user = detailBody.GetProperty("users").EnumerateArray().Single(); - Assert.Equal(TenantUserLogin, user.GetProperty("login").GetString()); - Assert.Equal(TenantId, user.GetProperty("tenantId").GetGuid()); - - Assert.Single(tenantStore.Tenants); - Assert.Equal(OperatorLogin, operatorStore.Operators.Single().Login); - }); - } - - [Fact] - public async Task Suspend_BlocksTenantLogin_ThenUnsuspend_RestoresLogin() - { - var auditStore = new FakeAuditLogStore(); - - await RunAsync( - async (baseAddress, operatorStore, userStore, tenantStore, _) => - { - // 1. suspend → 200 {ok,status:suspended} + аудит tenant_status_changed. - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using (HttpResponseMessage suspend = await operatorClient.PostAsync( - $"{baseAddress}/api/operator/tenants/{TenantId}/suspend", content: null)) - { - Assert.Equal(HttpStatusCode.OK, suspend.StatusCode); - JsonElement suspendBody = await ReadJsonAsync(suspend); - Assert.True(suspendBody.GetProperty("ok").GetBoolean()); - Assert.Equal(TenantStatuses.Suspended, suspendBody.GetProperty("status").GetString()); - } - - Assert.Equal(TenantStatuses.Suspended, tenantStore.Tenants.Single().Status); - AssertStatusChangedAudit(auditStore, operatorStore, TenantStatuses.Suspended); - - // 2. Логин пользователя приостановленного тенанта → 403 с текстом плана; failed-аудит с tenantId. - StoredUserDto user = userStore.Users.Single(); - HttpClient userClient = CreateClient(baseAddress); - using (HttpResponseMessage blocked = await PostJsonAsync( - userClient, $"{baseAddress}/api/auth/login", new { login = TenantUserLogin, password = TenantUserPassword })) - { - Assert.Equal(HttpStatusCode.Forbidden, blocked.StatusCode); - Assert.Equal(TenantSuspendedLoginDetail, (await ReadJsonAsync(blocked)).GetProperty("detail").GetString()); - } - - AuditRecordDto failedLogin = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.TenantLoginFailed); - Assert.Equal(AuditActorTypes.Tenant, failedLogin.ActorType); - Assert.Equal(user.Id, failedLogin.ActorId); - Assert.Equal(TenantId, failedLogin.TenantId); - AssertDetailLogin(failedLogin, TenantUserLogin); - - // 3. unsuspend → 200 {ok,status:active} + аудит tenant_status_changed. - using (HttpResponseMessage unsuspend = await operatorClient.PostAsync( - $"{baseAddress}/api/operator/tenants/{TenantId}/unsuspend", content: null)) - { - Assert.Equal(HttpStatusCode.OK, unsuspend.StatusCode); - JsonElement unsuspendBody = await ReadJsonAsync(unsuspend); - Assert.True(unsuspendBody.GetProperty("ok").GetBoolean()); - Assert.Equal(TenantStatuses.Active, unsuspendBody.GetProperty("status").GetString()); - } - - Assert.Equal(TenantStatuses.Active, tenantStore.Tenants.Single().Status); - AssertStatusChangedAudit(auditStore, operatorStore, TenantStatuses.Active); - - // 4. Возобновлённый тенант: login снова работает (200 + кука deal_session). - HttpClient restoredClient = CreateClient(baseAddress); - using HttpResponseMessage login = await PostJsonAsync( - restoredClient, $"{baseAddress}/api/auth/login", new { login = TenantUserLogin, password = TenantUserPassword }); - Assert.Equal(HttpStatusCode.OK, login.StatusCode); - Assert.True(login.Headers.Contains("Set-Cookie")); - }, - auditStore); - } - - [Fact] - public async Task Suspend_AlreadySuspendedTenant_IsOkWithoutDuplicateAudit() - { - var auditStore = new FakeAuditLogStore(); - var tenantStore = new FakeTenantStore( - new TenantRecordDto(TenantId, TenantName, TenantStatuses.Suspended, DateTimeOffset.UtcNow)); - - await RunAsync( - async (baseAddress, operatorStore, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage suspend = await operatorClient.PostAsync( - $"{baseAddress}/api/operator/tenants/{TenantId}/suspend", content: null); - - Assert.Equal(HttpStatusCode.OK, suspend.StatusCode); - Assert.Equal(TenantStatuses.Suspended, (await ReadJsonAsync(suspend)).GetProperty("status").GetString()); - // Идемпотентность: повторный suspend не пишет новый tenant_status_changed. - Assert.DoesNotContain(auditStore.Records, r => r.EventType == AuditEvents.TenantStatusChanged); - }, - auditStore, - tenantStore: tenantStore); - } - - [Fact] - public async Task Suspend_ForUnknownTenant_Returns404() - { - await RunAsync( - async (baseAddress, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage response = await operatorClient.PostAsync( - $"{baseAddress}/api/operator/tenants/{Guid.NewGuid()}/suspend", content: null); - - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - Assert.Equal(TenantNotFoundDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task Impersonate_ReturnsSessionToken_WorksAsDealSession_AndLogoutWritesStoppedAudit() - { - var auditStore = new FakeAuditLogStore(); - - await RunAsync( - async (baseAddress, operatorStore, userStore, _, _) => - { - // 1. Impersonate {login} → tenant-сессия пользователя + аудит impersonation_started. - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage impersonate = await PostJsonAsync( - operatorClient, $"{baseAddress}/api/operator/tenants/{TenantId}/impersonate", new { login = TenantUserLogin }); - Assert.Equal(HttpStatusCode.OK, impersonate.StatusCode); - JsonElement body = await ReadJsonAsync(impersonate); - string sessionToken = body.GetProperty("sessionToken").GetString()!; - Assert.Equal(TenantId, body.GetProperty("tenantId").GetGuid()); - Assert.Equal(TenantUserLogin, body.GetProperty("login").GetString()); - Assert.True(DateTimeOffset.TryParse(body.GetProperty("expiresAt").GetString(), out var expiresAt)); - Assert.InRange(expiresAt, DateTimeOffset.UtcNow.AddDays(AuthService.SessionLifetimeDays - 1), DateTimeOffset.UtcNow.AddDays(AuthService.SessionLifetimeDays)); - - StoredUserDto user = userStore.Users.Single(); - AuditRecordDto started = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.ImpersonationStarted); - Assert.Equal(AuditActorTypes.Operator, started.ActorType); - Assert.Equal(operatorStore.Operators.Single().Id, started.ActorId); - Assert.Equal(TenantId, started.TenantId); - Assert.NotNull(started.DetailJson); - using (var detail = JsonDocument.Parse(started.DetailJson!)) - { - Assert.Equal(TenantUserLogin, detail.RootElement.GetProperty("targetLogin").GetString()); - Assert.Equal(TenantId, detail.RootElement.GetProperty("tenantId").GetGuid()); - } - - // 2. Токен работает как deal_session на /api/auth/me (Acceptance Task 7), при этом операторский - // контур для него закрыт: tenant-сессия ≠ операторская (Ruling 1, изоляция кук). - HttpClient userClient = CreateClient(baseAddress); - using (HttpResponseMessage me = await GetWithTenantCookieAsync(userClient, baseAddress, sessionToken, "/api/auth/me")) - { - Assert.Equal(HttpStatusCode.OK, me.StatusCode); - JsonElement meBody = await ReadJsonAsync(me); - Assert.Equal(TenantUserLogin, meBody.GetProperty("login").GetString()); - } - - using (HttpResponseMessage operatorMe = await GetWithTenantCookieAsync(userClient, baseAddress, sessionToken, "/api/operator/auth/me")) - { - Assert.Equal(HttpStatusCode.Unauthorized, operatorMe.StatusCode); - } - - // 3. Logout пользователя завершает impersonation: аудит impersonation_stopped (актор — оператор). - using (HttpResponseMessage logout = await PostWithTenantCookieAsync(userClient, baseAddress, sessionToken, "/api/auth/logout")) - { - Assert.Equal(HttpStatusCode.OK, logout.StatusCode); - Assert.True((await ReadJsonAsync(logout)).GetProperty("ok").GetBoolean()); - } - - AuditRecordDto stopped = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.ImpersonationStopped); - Assert.Equal(AuditActorTypes.Operator, stopped.ActorType); - Assert.Equal(operatorStore.Operators.Single().Id, stopped.ActorId); - Assert.Equal(TenantId, stopped.TenantId); - AssertDetailLogin(stopped, TenantUserLogin); - - // Сессия удалена — тот же токен больше не резолвится. - using HttpResponseMessage meAfterLogout = await GetWithTenantCookieAsync(userClient, baseAddress, sessionToken, "/api/auth/me"); - Assert.Equal(HttpStatusCode.Unauthorized, meAfterLogout.StatusCode); - }, - auditStore); - } - - [Fact] - public async Task Impersonate_WithoutLogin_TakesOnlyTenantUser() - { - await RunAsync( - async (baseAddress, _, userStore, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - // Пустой login — первый пользователь тенанта (в тесте он один). - using HttpResponseMessage response = await operatorClient.PostAsync( - $"{baseAddress}/api/operator/tenants/{TenantId}/impersonate", content: null); - - Assert.Equal(HttpStatusCode.OK, response.StatusCode); - JsonElement body = await ReadJsonAsync(response); - Assert.Equal(userStore.Users.Single().Login, body.GetProperty("login").GetString()); - Assert.Equal(TenantId, body.GetProperty("tenantId").GetGuid()); - }); - } - - [Fact] - public async Task Impersonate_ForUnknownTenant_Returns404() - { - await RunAsync( - async (baseAddress, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage response = await PostJsonAsync( - operatorClient, $"{baseAddress}/api/operator/tenants/{Guid.NewGuid()}/impersonate", new { login = TenantUserLogin }); - - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - Assert.Equal(TenantNotFoundDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task Impersonate_WithLoginOfAnotherTenant_Returns404() - { - await RunAsync( - async (baseAddress, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage response = await PostJsonAsync( - operatorClient, $"{baseAddress}/api/operator/tenants/{TenantId}/impersonate", new { login = "other@tenant.com" }); - - Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); - Assert.Equal(UserNotFoundInTenantDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }); - } - - [Fact] - public async Task Impersonate_WhenTenantHasNoUsers_Returns400() - { - var emptyTenantId = Guid.NewGuid(); - var emptyTenantStore = new FakeTenantStore( - new TenantRecordDto(emptyTenantId, "Пустой тенант", TenantStatuses.Active, DateTimeOffset.UtcNow)); - - await RunAsync( - async (baseAddress, _, _, _, _) => - { - HttpClient operatorClient = CreateClient(baseAddress); - await LoginOperatorAsync(operatorClient, baseAddress); - - using HttpResponseMessage response = await operatorClient.PostAsync( - $"{baseAddress}/api/operator/tenants/{emptyTenantId}/impersonate", content: null); - - Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); - Assert.Equal(TenantHasNoUsersDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); - }, - tenantStore: emptyTenantStore); - } - - // ─── Хелперы ───────────────────────────────────────────────────────── - - // Прогоняет сценарий на хосте с одним активным тенантом и одним пользователем в нём. - private static Task RunAsync( - Func scenario, - FakeAuditLogStore? auditStore = null, - FakeTenantStore? tenantStore = null) - { - // По умолчанию в реестре — активный целевой тенант (пользователь в нём — NewUserStore). - FakeTenantStore effectiveTenantStore = tenantStore ?? - new FakeTenantStore(new TenantRecordDto(TenantId, TenantName, TenantStatuses.Active, DateTimeOffset.UtcNow)); - return OperatorAuthHttpHost.RunAsync( - NewOperatorStore(), - NewUserStore(), - async (baseAddress, operators, users, tenants, _, audit) => await scenario(baseAddress, operators, users, tenants, audit), - auditStore, - tenantStore: effectiveTenantStore); - } - - // Проверяет запись tenant_status_changed с заданным статусом и актором-оператором. - private static void AssertStatusChangedAudit(FakeAuditLogStore auditStore, FakeOperatorAuthStore operatorStore, string status) - { - // В ленте может быть несколько tenant_status_changed этого тенанта (suspend → unsuspend) — ищем по статусу. - AuditRecordDto record = auditStore.Records - .Where(r => r.EventType == AuditEvents.TenantStatusChanged && r.TenantId == TenantId) - .Single(r => StatusOf(r) == status); - Assert.Equal(AuditActorTypes.Operator, record.ActorType); - Assert.Equal(operatorStore.Operators.Single().Id, record.ActorId); - Assert.Equal(TenantId, record.TenantId); - Assert.NotNull(record.DetailJson); - using var detail = JsonDocument.Parse(record.DetailJson!); - Assert.Equal(TenantId, detail.RootElement.GetProperty("tenantId").GetGuid()); - Assert.Equal(status, detail.RootElement.GetProperty("status").GetString()); - } - - // Статус из DetailJson записи tenant_status_changed. - private static string? StatusOf(AuditRecordDto record) - { - Assert.NotNull(record.DetailJson); - using var detail = JsonDocument.Parse(record.DetailJson!); - return detail.RootElement.GetProperty("status").GetString(); - } - - // Проверяет DetailJson записи аудита: поле login. - private static void AssertDetailLogin(AuditRecordDto record, string login) - { - Assert.NotNull(record.DetailJson); - using var detail = JsonDocument.Parse(record.DetailJson!); - Assert.Equal(login, detail.RootElement.GetProperty("login").GetString()); - } - - // GET с tenant-кукой deal_session=rawToken (impersonation-токен как значение куки). - private static async Task GetWithTenantCookieAsync( - HttpClient client, string baseAddress, string rawToken, string path) - { - using var request = new HttpRequestMessage(HttpMethod.Get, $"{baseAddress}{path}"); - request.Headers.TryAddWithoutValidation("Cookie", $"{TenantCookieName}={rawToken}"); - return await client.SendAsync(request); - } - - // POST без тела с tenant-кукой deal_session=rawToken. - private static async Task PostWithTenantCookieAsync( - HttpClient client, string baseAddress, string rawToken, string path) - { - using var request = new HttpRequestMessage(HttpMethod.Post, $"{baseAddress}{path}"); - request.Headers.TryAddWithoutValidation("Cookie", $"{TenantCookieName}={rawToken}"); - return await client.SendAsync(request); - } - - // Логинит оператора (ожидается 200). - private static async Task LoginOperatorAsync(HttpClient client, string baseAddress) - { - using HttpResponseMessage login = await PostJsonAsync( - client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword }); - Assert.Equal(HttpStatusCode.OK, login.StatusCode); - } - - // Фейк-хранилище оператора с активным оператором operator/operator. - private static FakeOperatorAuthStore NewOperatorStore() - { - var passwordHasher = new FakePasswordHasher(); - var store = new FakeOperatorAuthStore(); - store.AddOperator(new StoredOperatorDto( - Guid.NewGuid(), - OperatorLogin, - Status: "active", - PasswordHash: passwordHasher.Hash(OperatorPassword))); - return store; - } - - // Фейк-хранилище пользователей с одним пользователем в целевом тенанте. - private static FakeAuthStore NewUserStore() - { - var passwordHasher = new FakePasswordHasher(); - var store = new FakeAuthStore(); - store.AddUser(new StoredUserDto( - Guid.NewGuid(), - TenantUserLogin, - TenantId, - Status: "active", - PasswordHash: passwordHasher.Hash(TenantUserPassword))); - return store; - } - - // HTTP-клиент с собственным CookieContainer (изоляция сценариев кук). - private static HttpClient CreateClient(string baseAddress) => - new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) - { - BaseAddress = new Uri(baseAddress), - }; - - // POST JSON-тела и возврат ответа. - private static async Task PostJsonAsync(HttpClient client, string url, object body) - { - string json = JsonSerializer.Serialize(body); - using var content = new StringContent(json, Encoding.UTF8, "application/json"); - return await client.PostAsync(url, content); - } - - // Читает тело ответа как JSON-документ (корень, отвязанный от документа). - private static async Task ReadJsonAsync(HttpResponseMessage response) - { - await using Stream stream = await response.Content.ReadAsStreamAsync(); - using var document = await JsonDocument.ParseAsync(stream); - return document.RootElement.Clone(); - } -} +using System.Net; +using System.Text; +using System.Text.Json; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Tests.Unit; + +/// +/// HTTP-тесты операторских ручек тенантов (план Task 7, Ruling 1/4/10/11): эквивалент curl-минимума +/// list → suspend → login заблокирован → unsuspend → login ok и impersonate → deal_session работает на /me. +/// +/// +/// Прогон на in-process Kestrel (OperatorAuthHttpHost) с фейк-хранилищами: реестр тенантов, пользователи и +/// аудит — настоящие сервисы модуля. Проверяются формы ответов, 401 без операторской сессии и аудит +/// (tenant_status_changed, tenant_login_failed с tenantId для suspended-тенанта, impersonation_started/stopped). +/// Живая curl-приёмка на :5080 — ⚠ Manual (нужен Postgres). +/// +public sealed class OperatorTenantsEndpointsHttpTests +{ + private const string OperatorLogin = "operator"; + private const string OperatorPassword = "operator"; + private const string TenantName = "Тенант для проверки"; + private const string TenantUserLogin = "user@example.com"; + private const string TenantUserPassword = "user-password"; + private const string TenantCookieName = "deal_session"; + private const string TenantNotFoundDetail = "Тенант не найден"; + private const string UserNotFoundInTenantDetail = "Пользователь не найден в тенанте"; + private const string TenantHasNoUsersDetail = "В тенанте нет пользователей для входа"; + private const string TenantSuspendedLoginDetail = "Учётная запись приостановлена. Обратитесь к оператору"; + private const string TenantNameRequiredDetail = "Имя тенанта обязательно"; + private const string EmailTakenDetail = "Этот email уже зарегистрирован"; + private const string NewTenantName = "Новый тенант оператора"; + + private static readonly Guid TenantId = Guid.NewGuid(); + + [Fact] + public async Task List_WithoutOperatorSession_Returns401() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + using HttpResponseMessage response = await CreateClient(baseAddress).GetAsync($"{baseAddress}/api/operator/tenants"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal("Требуется вход оператора", (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task Detail_WithoutOperatorSession_Returns401() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + using HttpResponseMessage response = await CreateClient(baseAddress).GetAsync($"{baseAddress}/api/operator/tenants/{TenantId}"); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + }); + } + + [Fact] + public async Task Suspend_WithoutOperatorSession_Returns401() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + using HttpResponseMessage response = await CreateClient(baseAddress).PostAsync( + $"{baseAddress}/api/operator/tenants/{TenantId}/suspend", content: null); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + }); + } + + [Fact] + public async Task Impersonate_WithoutOperatorSession_Returns401() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + using HttpResponseMessage response = await CreateClient(baseAddress).PostAsync( + $"{baseAddress}/api/operator/tenants/{TenantId}/impersonate", content: null); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + }); + } + + [Fact] + public async Task Create_WithoutOperatorSession_Returns401() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + using HttpResponseMessage response = await PostJsonAsync( + CreateClient(baseAddress), $"{baseAddress}/api/operator/tenants", new { name = NewTenantName }); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + Assert.Equal("Требуется вход оператора", (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task Create_ReturnsCreatedTenant_AndWritesTenantCreatedAudit() + { + var auditStore = new FakeAuditLogStore(); + + await RunAsync( + async (baseAddress, operatorStore, _, tenantStore, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage create = await PostJsonAsync( + operatorClient, $"{baseAddress}/api/operator/tenants", new { name = NewTenantName }); + Assert.Equal(HttpStatusCode.OK, create.StatusCode); + JsonElement body = await ReadJsonAsync(create); + Guid createdId = body.GetProperty("id").GetGuid(); + Assert.Equal(NewTenantName, body.GetProperty("name").GetString()); + Assert.Equal(TenantStatuses.Active, body.GetProperty("status").GetString()); + Assert.True(DateTimeOffset.TryParse(body.GetProperty("createdAt").GetString(), out _)); + Assert.False(body.TryGetProperty("ownerEmail", out _)); + + // Реестр: новый тенант (провижининг схемы — фейк в хосте; реальный — TenantProvisioningService ⚠ Manual). + TenantRecordDto stored = Assert.Single(tenantStore.Tenants, t => t.Id == createdId); + Assert.Equal(NewTenantName, stored.Name); + Assert.Equal(TenantStatuses.Active, stored.Status); + + // Аудит tenant_created: актор-оператор, TenantId нового тенанта, детали id+имя. + AuditRecordDto createdAudit = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.TenantCreated); + Assert.Equal(AuditActorTypes.Operator, createdAudit.ActorType); + Assert.Equal(operatorStore.Operators.Single().Id, createdAudit.ActorId); + Assert.Equal(createdId, createdAudit.TenantId); + Assert.NotNull(createdAudit.DetailJson); + using (var detail = JsonDocument.Parse(createdAudit.DetailJson!)) + { + Assert.Equal(createdId, detail.RootElement.GetProperty("tenantId").GetGuid()); + Assert.Equal(NewTenantName, detail.RootElement.GetProperty("name").GetString()); + } + }, + auditStore); + } + + [Fact] + public async Task Create_WithOwnerEmail_ReturnsOneTimePasswordAndCreatesOwner() + { + var auditStore = new FakeAuditLogStore(); + const string ownerEmail = "owner@created.com"; + + await RunAsync( + async (baseAddress, operatorStore, userStore, tenantStore, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage create = await PostJsonAsync( + operatorClient, $"{baseAddress}/api/operator/tenants", new { name = NewTenantName, email = ownerEmail }); + Assert.Equal(HttpStatusCode.OK, create.StatusCode); + JsonElement body = await ReadJsonAsync(create); + Guid createdId = body.GetProperty("id").GetGuid(); + Assert.Equal(ownerEmail, body.GetProperty("ownerEmail").GetString()); + string initialPassword = body.GetProperty("initialPassword").GetString()!; + Assert.NotEmpty(initialPassword); + + // Владелец создан в новом тенанте; raw-пароль в хранилище не хранится (только хэш). + StoredUserDto owner = Assert.Single(userStore.Users, u => u.Login == ownerEmail); + Assert.Equal(createdId, owner.TenantId); + Assert.NotEqual(initialPassword, owner.PasswordHash); + + AuditRecordDto createdAudit = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.TenantCreated); + Assert.Equal(operatorStore.Operators.Single().Id, createdAudit.ActorId); + Assert.Equal(createdId, createdAudit.TenantId); + }, + auditStore); + } + + [Fact] + public async Task Create_WithEmptyName_Returns400() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage response = await PostJsonAsync( + operatorClient, $"{baseAddress}/api/operator/tenants", new { name = " " }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(TenantNameRequiredDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task Create_WithTakenEmail_Returns400() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + // TenantUserLogin уже зарегистрирован (дефолтный пользователь сценария) — создание владельца с ним нельзя. + using HttpResponseMessage response = await PostJsonAsync( + operatorClient, $"{baseAddress}/api/operator/tenants", new { name = NewTenantName, email = TenantUserLogin }); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(EmailTakenDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task ListAndDetail_ReturnTenantWithCountersAndUsers() + { + await RunAsync( + async (baseAddress, operatorStore, _, tenantStore, _) => + { + HttpClient client = CreateClient(baseAddress); + await LoginOperatorAsync(client, baseAddress); + + // GET /api/operator/tenants: реестр + счётчик пользователей (поля лимитов — Task 8). + using HttpResponseMessage list = await client.GetAsync($"{baseAddress}/api/operator/tenants"); + Assert.Equal(HttpStatusCode.OK, list.StatusCode); + JsonElement item = (await ReadJsonAsync(list)).GetProperty("items").EnumerateArray().Single(); + Assert.Equal(TenantId, item.GetProperty("id").GetGuid()); + Assert.Equal(TenantName, item.GetProperty("name").GetString()); + Assert.Equal(TenantStatuses.Active, item.GetProperty("status").GetString()); + Assert.Equal(1, item.GetProperty("usersCount").GetInt32()); + + // GET /api/operator/tenants/{id}: детали + пользователи. + using HttpResponseMessage detail = await client.GetAsync($"{baseAddress}/api/operator/tenants/{TenantId}"); + Assert.Equal(HttpStatusCode.OK, detail.StatusCode); + JsonElement detailBody = await ReadJsonAsync(detail); + Assert.Equal(TenantName, detailBody.GetProperty("name").GetString()); + JsonElement user = detailBody.GetProperty("users").EnumerateArray().Single(); + Assert.Equal(TenantUserLogin, user.GetProperty("login").GetString()); + Assert.Equal(TenantId, user.GetProperty("tenantId").GetGuid()); + + Assert.Single(tenantStore.Tenants); + Assert.Equal(OperatorLogin, operatorStore.Operators.Single().Login); + }); + } + + [Fact] + public async Task Suspend_BlocksTenantLogin_ThenUnsuspend_RestoresLogin() + { + var auditStore = new FakeAuditLogStore(); + + await RunAsync( + async (baseAddress, operatorStore, userStore, tenantStore, _) => + { + // 1. suspend → 200 {ok,status:suspended} + аудит tenant_status_changed. + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using (HttpResponseMessage suspend = await operatorClient.PostAsync( + $"{baseAddress}/api/operator/tenants/{TenantId}/suspend", content: null)) + { + Assert.Equal(HttpStatusCode.OK, suspend.StatusCode); + JsonElement suspendBody = await ReadJsonAsync(suspend); + Assert.True(suspendBody.GetProperty("ok").GetBoolean()); + Assert.Equal(TenantStatuses.Suspended, suspendBody.GetProperty("status").GetString()); + } + + Assert.Equal(TenantStatuses.Suspended, tenantStore.Tenants.Single().Status); + AssertStatusChangedAudit(auditStore, operatorStore, TenantStatuses.Suspended); + + // 2. Логин пользователя приостановленного тенанта → 403 с текстом плана; failed-аудит с tenantId. + StoredUserDto user = userStore.Users.Single(); + HttpClient userClient = CreateClient(baseAddress); + using (HttpResponseMessage blocked = await PostJsonAsync( + userClient, $"{baseAddress}/api/auth/login", new { login = TenantUserLogin, password = TenantUserPassword })) + { + Assert.Equal(HttpStatusCode.Forbidden, blocked.StatusCode); + Assert.Equal(TenantSuspendedLoginDetail, (await ReadJsonAsync(blocked)).GetProperty("detail").GetString()); + } + + AuditRecordDto failedLogin = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.TenantLoginFailed); + Assert.Equal(AuditActorTypes.Tenant, failedLogin.ActorType); + Assert.Equal(user.Id, failedLogin.ActorId); + Assert.Equal(TenantId, failedLogin.TenantId); + AssertDetailLogin(failedLogin, TenantUserLogin); + + // 3. unsuspend → 200 {ok,status:active} + аудит tenant_status_changed. + using (HttpResponseMessage unsuspend = await operatorClient.PostAsync( + $"{baseAddress}/api/operator/tenants/{TenantId}/unsuspend", content: null)) + { + Assert.Equal(HttpStatusCode.OK, unsuspend.StatusCode); + JsonElement unsuspendBody = await ReadJsonAsync(unsuspend); + Assert.True(unsuspendBody.GetProperty("ok").GetBoolean()); + Assert.Equal(TenantStatuses.Active, unsuspendBody.GetProperty("status").GetString()); + } + + Assert.Equal(TenantStatuses.Active, tenantStore.Tenants.Single().Status); + AssertStatusChangedAudit(auditStore, operatorStore, TenantStatuses.Active); + + // 4. Возобновлённый тенант: login снова работает (200 + кука deal_session). + HttpClient restoredClient = CreateClient(baseAddress); + using HttpResponseMessage login = await PostJsonAsync( + restoredClient, $"{baseAddress}/api/auth/login", new { login = TenantUserLogin, password = TenantUserPassword }); + Assert.Equal(HttpStatusCode.OK, login.StatusCode); + Assert.True(login.Headers.Contains("Set-Cookie")); + }, + auditStore); + } + + [Fact] + public async Task Suspend_AlreadySuspendedTenant_IsOkWithoutDuplicateAudit() + { + var auditStore = new FakeAuditLogStore(); + var tenantStore = new FakeTenantStore( + new TenantRecordDto(TenantId, TenantName, TenantStatuses.Suspended, DateTimeOffset.UtcNow)); + + await RunAsync( + async (baseAddress, operatorStore, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage suspend = await operatorClient.PostAsync( + $"{baseAddress}/api/operator/tenants/{TenantId}/suspend", content: null); + + Assert.Equal(HttpStatusCode.OK, suspend.StatusCode); + Assert.Equal(TenantStatuses.Suspended, (await ReadJsonAsync(suspend)).GetProperty("status").GetString()); + // Идемпотентность: повторный suspend не пишет новый tenant_status_changed. + Assert.DoesNotContain(auditStore.Records, r => r.EventType == AuditEvents.TenantStatusChanged); + }, + auditStore, + tenantStore: tenantStore); + } + + [Fact] + public async Task Suspend_ForUnknownTenant_Returns404() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage response = await operatorClient.PostAsync( + $"{baseAddress}/api/operator/tenants/{Guid.NewGuid()}/suspend", content: null); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(TenantNotFoundDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task Impersonate_ReturnsSessionToken_WorksAsDealSession_AndLogoutWritesStoppedAudit() + { + var auditStore = new FakeAuditLogStore(); + + await RunAsync( + async (baseAddress, operatorStore, userStore, _, _) => + { + // 1. Impersonate {login} → tenant-сессия пользователя + аудит impersonation_started. + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage impersonate = await PostJsonAsync( + operatorClient, $"{baseAddress}/api/operator/tenants/{TenantId}/impersonate", new { login = TenantUserLogin }); + Assert.Equal(HttpStatusCode.OK, impersonate.StatusCode); + JsonElement body = await ReadJsonAsync(impersonate); + string sessionToken = body.GetProperty("sessionToken").GetString()!; + Assert.Equal(TenantId, body.GetProperty("tenantId").GetGuid()); + Assert.Equal(TenantUserLogin, body.GetProperty("login").GetString()); + Assert.True(DateTimeOffset.TryParse(body.GetProperty("expiresAt").GetString(), out var expiresAt)); + Assert.InRange(expiresAt, DateTimeOffset.UtcNow.AddDays(AuthService.SessionLifetimeDays - 1), DateTimeOffset.UtcNow.AddDays(AuthService.SessionLifetimeDays)); + + StoredUserDto user = userStore.Users.Single(); + AuditRecordDto started = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.ImpersonationStarted); + Assert.Equal(AuditActorTypes.Operator, started.ActorType); + Assert.Equal(operatorStore.Operators.Single().Id, started.ActorId); + Assert.Equal(TenantId, started.TenantId); + Assert.NotNull(started.DetailJson); + using (var detail = JsonDocument.Parse(started.DetailJson!)) + { + Assert.Equal(TenantUserLogin, detail.RootElement.GetProperty("targetLogin").GetString()); + Assert.Equal(TenantId, detail.RootElement.GetProperty("tenantId").GetGuid()); + } + + // 2. Токен работает как deal_session на /api/auth/me (Acceptance Task 7), при этом операторский + // контур для него закрыт: tenant-сессия ≠ операторская (Ruling 1, изоляция кук). + HttpClient userClient = CreateClient(baseAddress); + using (HttpResponseMessage me = await GetWithTenantCookieAsync(userClient, baseAddress, sessionToken, "/api/auth/me")) + { + Assert.Equal(HttpStatusCode.OK, me.StatusCode); + JsonElement meBody = await ReadJsonAsync(me); + Assert.Equal(TenantUserLogin, meBody.GetProperty("login").GetString()); + } + + using (HttpResponseMessage operatorMe = await GetWithTenantCookieAsync(userClient, baseAddress, sessionToken, "/api/operator/auth/me")) + { + Assert.Equal(HttpStatusCode.Unauthorized, operatorMe.StatusCode); + } + + // 3. Logout пользователя завершает impersonation: аудит impersonation_stopped (актор — оператор). + using (HttpResponseMessage logout = await PostWithTenantCookieAsync(userClient, baseAddress, sessionToken, "/api/auth/logout")) + { + Assert.Equal(HttpStatusCode.OK, logout.StatusCode); + Assert.True((await ReadJsonAsync(logout)).GetProperty("ok").GetBoolean()); + } + + AuditRecordDto stopped = Assert.Single(auditStore.Records, r => r.EventType == AuditEvents.ImpersonationStopped); + Assert.Equal(AuditActorTypes.Operator, stopped.ActorType); + Assert.Equal(operatorStore.Operators.Single().Id, stopped.ActorId); + Assert.Equal(TenantId, stopped.TenantId); + AssertDetailLogin(stopped, TenantUserLogin); + + // Сессия удалена — тот же токен больше не резолвится. + using HttpResponseMessage meAfterLogout = await GetWithTenantCookieAsync(userClient, baseAddress, sessionToken, "/api/auth/me"); + Assert.Equal(HttpStatusCode.Unauthorized, meAfterLogout.StatusCode); + }, + auditStore); + } + + [Fact] + public async Task Impersonate_WithoutLogin_TakesOnlyTenantUser() + { + await RunAsync( + async (baseAddress, _, userStore, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + // Пустой login — первый пользователь тенанта (в тесте он один). + using HttpResponseMessage response = await operatorClient.PostAsync( + $"{baseAddress}/api/operator/tenants/{TenantId}/impersonate", content: null); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + JsonElement body = await ReadJsonAsync(response); + Assert.Equal(userStore.Users.Single().Login, body.GetProperty("login").GetString()); + Assert.Equal(TenantId, body.GetProperty("tenantId").GetGuid()); + }); + } + + [Fact] + public async Task Impersonate_ForUnknownTenant_Returns404() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage response = await PostJsonAsync( + operatorClient, $"{baseAddress}/api/operator/tenants/{Guid.NewGuid()}/impersonate", new { login = TenantUserLogin }); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(TenantNotFoundDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task Impersonate_WithLoginOfAnotherTenant_Returns404() + { + await RunAsync( + async (baseAddress, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage response = await PostJsonAsync( + operatorClient, $"{baseAddress}/api/operator/tenants/{TenantId}/impersonate", new { login = "other@tenant.com" }); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal(UserNotFoundInTenantDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }); + } + + [Fact] + public async Task Impersonate_WhenTenantHasNoUsers_Returns400() + { + var emptyTenantId = Guid.NewGuid(); + var emptyTenantStore = new FakeTenantStore( + new TenantRecordDto(emptyTenantId, "Пустой тенант", TenantStatuses.Active, DateTimeOffset.UtcNow)); + + await RunAsync( + async (baseAddress, _, _, _, _) => + { + HttpClient operatorClient = CreateClient(baseAddress); + await LoginOperatorAsync(operatorClient, baseAddress); + + using HttpResponseMessage response = await operatorClient.PostAsync( + $"{baseAddress}/api/operator/tenants/{emptyTenantId}/impersonate", content: null); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal(TenantHasNoUsersDetail, (await ReadJsonAsync(response)).GetProperty("detail").GetString()); + }, + tenantStore: emptyTenantStore); + } + + // ─── Хелперы ───────────────────────────────────────────────────────── + + // Прогоняет сценарий на хосте с одним активным тенантом и одним пользователем в нём. + private static Task RunAsync( + Func scenario, + FakeAuditLogStore? auditStore = null, + FakeTenantStore? tenantStore = null) + { + // По умолчанию в реестре — активный целевой тенант (пользователь в нём — NewUserStore). + FakeTenantStore effectiveTenantStore = tenantStore ?? + new FakeTenantStore(new TenantRecordDto(TenantId, TenantName, TenantStatuses.Active, DateTimeOffset.UtcNow)); + return OperatorAuthHttpHost.RunAsync( + NewOperatorStore(), + NewUserStore(), + async (baseAddress, operators, users, tenants, _, audit) => await scenario(baseAddress, operators, users, tenants, audit), + auditStore, + tenantStore: effectiveTenantStore); + } + + // Проверяет запись tenant_status_changed с заданным статусом и актором-оператором. + private static void AssertStatusChangedAudit(FakeAuditLogStore auditStore, FakeOperatorAuthStore operatorStore, string status) + { + // В ленте может быть несколько tenant_status_changed этого тенанта (suspend → unsuspend) — ищем по статусу. + AuditRecordDto record = auditStore.Records + .Where(r => r.EventType == AuditEvents.TenantStatusChanged && r.TenantId == TenantId) + .Single(r => StatusOf(r) == status); + Assert.Equal(AuditActorTypes.Operator, record.ActorType); + Assert.Equal(operatorStore.Operators.Single().Id, record.ActorId); + Assert.Equal(TenantId, record.TenantId); + Assert.NotNull(record.DetailJson); + using var detail = JsonDocument.Parse(record.DetailJson!); + Assert.Equal(TenantId, detail.RootElement.GetProperty("tenantId").GetGuid()); + Assert.Equal(status, detail.RootElement.GetProperty("status").GetString()); + } + + // Статус из DetailJson записи tenant_status_changed. + private static string? StatusOf(AuditRecordDto record) + { + Assert.NotNull(record.DetailJson); + using var detail = JsonDocument.Parse(record.DetailJson!); + return detail.RootElement.GetProperty("status").GetString(); + } + + // Проверяет DetailJson записи аудита: поле login. + private static void AssertDetailLogin(AuditRecordDto record, string login) + { + Assert.NotNull(record.DetailJson); + using var detail = JsonDocument.Parse(record.DetailJson!); + Assert.Equal(login, detail.RootElement.GetProperty("login").GetString()); + } + + // GET с tenant-кукой deal_session=rawToken (impersonation-токен как значение куки). + private static async Task GetWithTenantCookieAsync( + HttpClient client, string baseAddress, string rawToken, string path) + { + using var request = new HttpRequestMessage(HttpMethod.Get, $"{baseAddress}{path}"); + request.Headers.TryAddWithoutValidation("Cookie", $"{TenantCookieName}={rawToken}"); + return await client.SendAsync(request); + } + + // POST без тела с tenant-кукой deal_session=rawToken. + private static async Task PostWithTenantCookieAsync( + HttpClient client, string baseAddress, string rawToken, string path) + { + using var request = new HttpRequestMessage(HttpMethod.Post, $"{baseAddress}{path}"); + request.Headers.TryAddWithoutValidation("Cookie", $"{TenantCookieName}={rawToken}"); + return await client.SendAsync(request); + } + + // Логинит оператора (ожидается 200). + private static async Task LoginOperatorAsync(HttpClient client, string baseAddress) + { + using HttpResponseMessage login = await PostJsonAsync( + client, $"{baseAddress}/api/operator/auth/login", new { login = OperatorLogin, password = OperatorPassword }); + Assert.Equal(HttpStatusCode.OK, login.StatusCode); + } + + // Фейк-хранилище оператора с активным оператором operator/operator. + private static FakeOperatorAuthStore NewOperatorStore() + { + var passwordHasher = new FakePasswordHasher(); + var store = new FakeOperatorAuthStore(); + store.AddOperator(new StoredOperatorDto( + Guid.NewGuid(), + OperatorLogin, + Status: "active", + PasswordHash: passwordHasher.Hash(OperatorPassword))); + return store; + } + + // Фейк-хранилище пользователей с одним пользователем в целевом тенанте. + private static FakeAuthStore NewUserStore() + { + var passwordHasher = new FakePasswordHasher(); + var store = new FakeAuthStore(); + store.AddUser(new StoredUserDto( + Guid.NewGuid(), + TenantUserLogin, + TenantId, + Status: "active", + PasswordHash: passwordHasher.Hash(TenantUserPassword))); + return store; + } + + // HTTP-клиент с собственным CookieContainer (изоляция сценариев кук). + private static HttpClient CreateClient(string baseAddress) => + new(new HttpClientHandler { UseCookies = true, CookieContainer = new CookieContainer() }) + { + BaseAddress = new Uri(baseAddress), + }; + + // POST JSON-тела и возврат ответа. + private static async Task PostJsonAsync(HttpClient client, string url, object body) + { + string json = JsonSerializer.Serialize(body); + using var content = new StringContent(json, Encoding.UTF8, "application/json"); + return await client.PostAsync(url, content); + } + + // Читает тело ответа как JSON-документ (корень, отвязанный от документа). + private static async Task ReadJsonAsync(HttpResponseMessage response) + { + await using Stream stream = await response.Content.ReadAsStreamAsync(); + using var document = await JsonDocument.ParseAsync(stream); + return document.RootElement.Clone(); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/PasswordHasherTests.cs b/src/core/tests/Deal.Tests.Unit/PasswordHasherTests.cs index 9b96e75..05d2910 100644 --- a/src/core/tests/Deal.Tests.Unit/PasswordHasherTests.cs +++ b/src/core/tests/Deal.Tests.Unit/PasswordHasherTests.cs @@ -1,4 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/PipelineCardWriterTests.cs b/src/core/tests/Deal.Tests.Unit/PipelineCardWriterTests.cs index be25b4b..3fa8b6e 100644 --- a/src/core/tests/Deal.Tests.Unit/PipelineCardWriterTests.cs +++ b/src/core/tests/Deal.Tests.Unit/PipelineCardWriterTests.cs @@ -1,8 +1,13 @@ using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/PipelineIngestServiceTests.cs b/src/core/tests/Deal.Tests.Unit/PipelineIngestServiceTests.cs index 76aa791..174f731 100644 --- a/src/core/tests/Deal.Tests.Unit/PipelineIngestServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/PipelineIngestServiceTests.cs @@ -1,5 +1,7 @@ -using Deal.Modules.Pipeline.Application; +using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/PipelineProcessingServiceTests.cs b/src/core/tests/Deal.Tests.Unit/PipelineProcessingServiceTests.cs index f2502b5..19404c3 100644 --- a/src/core/tests/Deal.Tests.Unit/PipelineProcessingServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/PipelineProcessingServiceTests.cs @@ -1,5 +1,7 @@ -using Deal.Modules.Pipeline.Application; +using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/PipelineWorkerGrpcAiTests.cs b/src/core/tests/Deal.Tests.Unit/PipelineWorkerGrpcAiTests.cs index c105fdf..7988c61 100644 --- a/src/core/tests/Deal.Tests.Unit/PipelineWorkerGrpcAiTests.cs +++ b/src/core/tests/Deal.Tests.Unit/PipelineWorkerGrpcAiTests.cs @@ -3,13 +3,25 @@ using Deal.Contracts.Integrations.Models; using Deal.Grpc.Ai; using Deal.Infrastructure.Data; using Deal.Infrastructure.Integrations; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants; using Microsoft.Extensions.Logging.Abstractions; diff --git a/src/core/tests/Deal.Tests.Unit/PipelineWorkerSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/PipelineWorkerSchedulerTests.cs index d955bc6..87ed37f 100644 --- a/src/core/tests/Deal.Tests.Unit/PipelineWorkerSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/PipelineWorkerSchedulerTests.cs @@ -1,308 +1,319 @@ -using System.Text.Json; -using Deal.Api; -using Deal.Api.Events; -using Deal.Contracts.Integrations; -using Deal.Contracts.Integrations.Models; -using Deal.Infrastructure.Data; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Tenants; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Deal.Tests.Unit; - -/// -/// Тесты PipelineWorkerScheduler — фоновый цикл разбора очереди входящих (план Task 11, Ruling 8; -/// аналог _pipeline_loop main.py L79–88): каждые 2 с обход ВСЕХ тенантов реестра, на каждый — собственный -/// scope с ITenantContext, pump PipelineWorkerService под общим PipelinePumpGate и SSE new_card по созданным -/// карточкам. -/// -/// -/// Тайминги цикла (Timer 2 с, первый проход, stop) не тестируются — тестируется тело прохода RunCycleAsync -/// (как StorageTickSchedulerTests). Провайдер собирает РЕАЛЬНЫЕ сервисы модуля Pipeline на тенант-фейках -/// (FakePipelineStore/FakeKanjStore по ITenantContext — эталон StorageTickSchedulerTests): воркер ходит тем же -/// путём, что и в проде (SetTenant → scoped-резолв → PumpOnce). ML «спит» (FakeMlClient.Predict не задан → -/// сбой → «не уверен» → filtered), ИИ — FakeAiClassifier с разбором вакансии → карточка inbox (путь как в -/// AdminTickOrchestratorTests). Возраст строк — «сейчас» (stale-проверка воркера не срабатывает). -/// -public sealed class PipelineWorkerSchedulerTests -{ - // Тенант A теста (канал подписки). - private static readonly Guid TenantA = Guid.NewGuid(); - - // Тенант B теста (канал подписки). - private static readonly Guid TenantB = Guid.NewGuid(); - - // Контекст теста: планировщик на общих фейках + каналы подписок тенантов. - private sealed record Context( - PipelineWorkerScheduler Scheduler, - FakePipelineStore PipelineA, - FakeKanjStore KanjA, - SseSubscription SubscriptionA, - FakePipelineStore PipelineB, - FakeKanjStore KanjB, - SseSubscription SubscriptionB, - PipelinePumpGate PumpGate, - TenantContext TenantContext, - ListLogger Logs); - - // ─── Цикл: pump каждого тенанта в собственном scope + new_card ───────── - - [Fact] - public async Task RunCycle_PumpsEveryTenantQueueAndPublishesNewLeadPerCard() - { - Context ctx = CreateContext(); - ctx.PipelineA.SeedQueue(QueueRow("p_a_1", VacancyText, "d_a")); - ctx.PipelineB.SeedQueue(QueueRow("p_b_1", VacancyText, "d_b")); - - await ctx.Scheduler.RunCycleAsync(CancellationToken.None); - - // Каждый тенант отpump'ился в собственном scope: очередь пуста, карточка создана в «Неразобранном». - Assert.Empty(ctx.PipelineA.Queue); - Assert.Empty(ctx.PipelineB.Queue); - CardDto cardA = Assert.Single(ctx.KanjA.CardDtos); - CardDto cardB = Assert.Single(ctx.KanjB.CardDtos); - Assert.Equal(KanbanColumns.Inbox, cardA.Col); - Assert.Equal(KanbanColumns.Inbox, cardB.Col); - - // SSE new_card — по карточке каждого тенанта, в канал тенанта (Ruling 8/9). - Assert.Equal([cardA.Id], ReadNewLeadIds(ctx.SubscriptionA)); - Assert.Equal([cardB.Id], ReadNewLeadIds(ctx.SubscriptionB)); - - // Контекст AsyncLocal не должен переживать проход (Reset в finally каждого pump). - Assert.False(ctx.TenantContext.HasTenant); - } - - [Fact] - public async Task RunCycle_EmptyQueues_NoEventsAndNoTenantLeak() - { - Context ctx = CreateContext(); - - await ctx.Scheduler.RunCycleAsync(CancellationToken.None); - - // Пустые очереди — тихий no-op (прототип: pump на пустой очереди возвращает нули): публикаций нет. - Assert.False(ctx.SubscriptionA.Events.TryRead(out _)); - Assert.False(ctx.SubscriptionB.Events.TryRead(out _)); - Assert.False(ctx.TenantContext.HasTenant); - } - - // ─── Цикл: сбой pump тенанта не роняет проход ────────────────────────── - - [Fact] - public async Task RunCycle_TenantPumpFailure_DoesNotAbortOtherTenants() - { - // Pump тенанта A падает на чтении очереди (имитация сбоя схемы/БД) — A логируется, B обрабатывается. - Context ctx = CreateContext(withThrowingQueueReadA: true); - ctx.PipelineA.SeedQueue(QueueRow("p_a_1", VacancyText, "d_a")); - ctx.PipelineB.SeedQueue(QueueRow("p_b_1", VacancyText, "d_b")); - - await ctx.Scheduler.RunCycleAsync(CancellationToken.None); - - // Pump A не удался (лог планировщика) — строка A осталась, карточки A нет; B обработан штатно. - Assert.Single(ctx.PipelineA.Queue); - Assert.Empty(ctx.KanjA.CardDtos); - Assert.False(ctx.SubscriptionA.Events.TryRead(out _)); - Assert.Contains(ctx.Logs.Messages, message => message.Contains("pump тенанта") && message.Contains("ListAsync")); - Assert.Empty(ctx.PipelineB.Queue); - CardDto cardB = Assert.Single(ctx.KanjB.CardDtos); - Assert.Equal([cardB.Id], ReadNewLeadIds(ctx.SubscriptionB)); - Assert.False(ctx.TenantContext.HasTenant); - } - - // ─── Цикл: общий гейт с admin/tick ───────────────────────────────────── - - [Fact] - public async Task RunCycle_GateBusyForTenant_SkipsPumpAndKeepsQueue() - { - Context ctx = CreateContext(); - ctx.PipelineA.SeedQueue(QueueRow("p_a_1", VacancyText, "d_a")); - ctx.PipelineB.SeedQueue(QueueRow("p_b_1", VacancyText, "d_b")); - - // Очередь тенанта A уже разбирает другой воркер (ручной POST /api/admin/tick): гейт занят — цикл - // пропускает A (как прототип L901–902: занятый lock → {}), очередь ждёт следующего срабатывания. - Assert.True(ctx.PumpGate.TryEnter(TenantA)); - await ctx.Scheduler.RunCycleAsync(CancellationToken.None); - - Assert.Single(ctx.PipelineA.Queue); - Assert.Empty(ctx.KanjA.CardDtos); - Assert.False(ctx.SubscriptionA.Events.TryRead(out _)); - Assert.Empty(ctx.PipelineB.Queue); - CardDto cardB = Assert.Single(ctx.KanjB.CardDtos); - Assert.Equal([cardB.Id], ReadNewLeadIds(ctx.SubscriptionB)); - - // Гейт A остался за ручным тиком (цикл его не освобождал — не захватывал); после тика гейт свободен. - Assert.False(ctx.PumpGate.TryEnter(TenantA)); - ctx.PumpGate.Exit(TenantA); - Assert.True(ctx.PumpGate.TryEnter(TenantA)); - ctx.PumpGate.Exit(TenantA); - Assert.False(ctx.TenantContext.HasTenant); - } - - // ─── Контекст и хелперы ───────────────────────────────────────────────── - - // Текст вакансии-сценария: проходит правила, дедуп, ML «спит» → ИИ-путь → карточка inbox. - private const string VacancyText = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; - - // Собирает планировщик на реальных сервисах модуля Pipeline и тенант-фейках (без таймера). - // withThrowingQueueReadA: true — чтение очереди тенанта A бросает (сценарий сбоя pump). - // Возвращает: Планировщик, фейки очередей/канбана тенантов, каналы подписок и гейт. - private static Context CreateContext(bool withThrowingQueueReadA = false) - { - var tenants = new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)); - var tenantContext = new TenantContext(); - FakePipelineStore pipelineA = withThrowingQueueReadA ? new ThrowingQueueReadPipelineStore() : new FakePipelineStore(); - var pipelineB = new FakePipelineStore(); - var kanjA = new FakeKanjStore(); - var kanjB = new FakeKanjStore(); - var settingsA = new FakeSettingsStore(); - var settingsB = new FakeSettingsStore(); - var pumpGate = new PipelinePumpGate(); - var aiClassifier = new FakeAiClassifier - { - ClassifyResult = Parsed("Python-разработчик в команду", isVacancy: true), - }; - - var services = new ServiceCollection(); - services.AddSingleton(tenantContext); - services.AddSingleton(); - services.AddSingleton(tenants); - services.AddSingleton(pumpGate); - services.AddSingleton(new FakeMlClient()); - services.AddSingleton(aiClassifier); - // Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant - // (эталон StorageTickSchedulerTests/ConnectionStringProvider.ForTenant). - services.AddScoped(provider => TenantOf(provider) == TenantA ? kanjA : kanjB); - services.AddScoped(provider => TenantOf(provider) == TenantA ? settingsA : settingsB); - services.AddScoped(provider => TenantOf(provider) == TenantA ? pipelineA : pipelineB); - // Реальные сервисы модуля Pipeline — как AddPipelineModule в Program.cs: цикл резолвит их в tenant-scope. - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - - ServiceProvider provider = services.BuildServiceProvider(); - SseBroker broker = provider.GetRequiredService(); - var logs = new ListLogger(); - var scheduler = new PipelineWorkerScheduler( - provider.GetRequiredService(), - pumpGate, - broker, - logs); - - return new Context( - scheduler, - pipelineA, - kanjA, - broker.Subscribe(TenantA), - pipelineB, - kanjB, - broker.Subscribe(TenantB), - pumpGate, - tenantContext, - logs); - } - - // Id текущего тенанта из контекста (резолвер фейков, как ConnectionStringProvider.ForTenant). - // provider: Scope, в котором выполняется pump тенанта. - // Возвращает: Guid тенанта из TenantId (формат N). - private static Guid TenantOf(IServiceProvider provider) - { - TenantId tenantId = provider.GetRequiredService().TenantId - ?? throw new InvalidOperationException("Тест: pump вне tenant-контекста (SetTenant не выполнен)"); - return Guid.Parse(tenantId.Value); - } - - // Запись реестра тенанта (как строка public.tenants). - // id: Идентификатор тенанта. - // Возвращает: Запись тенанта со статусом active. - private static TenantRecordDto Tenant(Guid id) => - new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow); - - // Строка очереди (status new; сценарий перекрывает текст/время — сейчас, stale не сработает). - private static QueueItemDto QueueRow(string id, string text, string dialogId) => new() - { - Id = id, - DialogId = dialogId, - MsgId = 100, - Text = text, - Status = PipelineQueueStatuses.New, - Channel = new PipelineChannelDto("Канал", "channel", "#333"), - MsgAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), - QueuedAtMs = 1_700_000_000_000, - }; - - // Минимальный разбор карточки (карточку домыслит CardComposer; колонка не назначена → inbox). - private static AiParsedCardDto Parsed(string title, bool isVacancy = false) => new( - title, - Company: null, - Format: null, - Task: null, - Requirements: null, - Plus: null, - Conditions: null, - Summary: null, - Stack: Array.Empty(), - Budget: null, - Contacts: Array.Empty(), - isVacancy, - IsVacancyKnown: false, - IsSpam: false, - Board: null); - - // Id карточек из new_card-событий канала в порядке публикации. - // subscription: Подписка канала тенанта. - // Возвращает: Id созданных карточек (поле id payload'а new_card). - private static List ReadNewLeadIds(SseSubscription subscription) - { - var cardIds = new List(); - while (subscription.Events.TryRead(out SseEvent? sseEvent)) - { - Assert.Equal("new_card", sseEvent!.Type); - using JsonDocument payload = JsonDocument.Parse(sseEvent.Json); - cardIds.Add(payload.RootElement.GetProperty("id").GetString()!); - } - - return cardIds; - } - - // Хранилище со сбоем чтения очереди: ListAsync бросает (сценарий «БД/схема недоступны» на pump). - private sealed class ThrowingQueueReadPipelineStore : FakePipelineStore - { - /// - public override Task> ListAsync(string? status, int limit, CancellationToken ct) - { - throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync)."); - } - } - - // Логгер-коллектор: копит сообщения планировщика (диагностика в тестах сбоя pump). - private sealed class ListLogger : ILogger - { - /// - /// Отформатированные сообщения лога в порядке записи. - /// - public List Messages { get; } = []; - - /// - public IDisposable? BeginScope(TState state) where TState : notnull => null; - - /// - public bool IsEnabled(LogLevel logLevel) => true; - - /// - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) - { - Messages.Add(exception is null ? formatter(state, exception) : formatter(state, exception) + " :: " + exception); - } - } -} +using System.Text.Json; +using Deal.Api; +using Deal.Api.Events; +using Deal.Contracts.Integrations; +using Deal.Contracts.Integrations.Models; +using Deal.Infrastructure.Data; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Pipeline.Application.Parse; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; +using Deal.SharedKernel.Tenants; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Deal.Tests.Unit; + +/// +/// Тесты PipelineWorkerScheduler — фоновый цикл разбора очереди входящих (план Task 11, Ruling 8; +/// аналог _pipeline_loop main.py L79–88): каждые 2 с обход ВСЕХ тенантов реестра, на каждый — собственный +/// scope с ITenantContext, pump PipelineWorkerService под общим PipelinePumpGate и SSE new_card по созданным +/// карточкам. +/// +/// +/// Тайминги цикла (Timer 2 с, первый проход, stop) не тестируются — тестируется тело прохода RunCycleAsync +/// (как StorageTickSchedulerTests). Провайдер собирает РЕАЛЬНЫЕ сервисы модуля Pipeline на тенант-фейках +/// (FakePipelineStore/FakeKanjStore по ITenantContext — эталон StorageTickSchedulerTests): воркер ходит тем же +/// путём, что и в проде (SetTenant → scoped-резолв → PumpOnce). ML «спит» (FakeMlClient.Predict не задан → +/// сбой → «не уверен» → filtered), ИИ — FakeAiClassifier с разбором вакансии → карточка inbox (путь как в +/// AdminTickOrchestratorTests). Возраст строк — «сейчас» (stale-проверка воркера не срабатывает). +/// +public sealed class PipelineWorkerSchedulerTests +{ + // Тенант A теста (канал подписки). + private static readonly Guid TenantA = Guid.NewGuid(); + + // Тенант B теста (канал подписки). + private static readonly Guid TenantB = Guid.NewGuid(); + + // Контекст теста: планировщик на общих фейках + каналы подписок тенантов. + private sealed record Context( + PipelineWorkerScheduler Scheduler, + FakePipelineStore PipelineA, + FakeKanjStore KanjA, + SseSubscription SubscriptionA, + FakePipelineStore PipelineB, + FakeKanjStore KanjB, + SseSubscription SubscriptionB, + PipelinePumpGate PumpGate, + TenantContext TenantContext, + ListLogger Logs); + + // ─── Цикл: pump каждого тенанта в собственном scope + new_card ───────── + + [Fact] + public async Task RunCycle_PumpsEveryTenantQueueAndPublishesNewLeadPerCard() + { + Context ctx = CreateContext(); + ctx.PipelineA.SeedQueue(QueueRow("p_a_1", VacancyText, "d_a")); + ctx.PipelineB.SeedQueue(QueueRow("p_b_1", VacancyText, "d_b")); + + await ctx.Scheduler.RunCycleAsync(CancellationToken.None); + + // Каждый тенант отpump'ился в собственном scope: очередь пуста, карточка создана в «Неразобранном». + Assert.Empty(ctx.PipelineA.Queue); + Assert.Empty(ctx.PipelineB.Queue); + CardDto cardA = Assert.Single(ctx.KanjA.CardDtos); + CardDto cardB = Assert.Single(ctx.KanjB.CardDtos); + Assert.Equal(KanbanColumns.Inbox, cardA.Col); + Assert.Equal(KanbanColumns.Inbox, cardB.Col); + + // SSE new_card — по карточке каждого тенанта, в канал тенанта (Ruling 8/9). + Assert.Equal([cardA.Id], ReadNewLeadIds(ctx.SubscriptionA)); + Assert.Equal([cardB.Id], ReadNewLeadIds(ctx.SubscriptionB)); + + // Контекст AsyncLocal не должен переживать проход (Reset в finally каждого pump). + Assert.False(ctx.TenantContext.HasTenant); + } + + [Fact] + public async Task RunCycle_EmptyQueues_NoEventsAndNoTenantLeak() + { + Context ctx = CreateContext(); + + await ctx.Scheduler.RunCycleAsync(CancellationToken.None); + + // Пустые очереди — тихий no-op (прототип: pump на пустой очереди возвращает нули): публикаций нет. + Assert.False(ctx.SubscriptionA.Events.TryRead(out _)); + Assert.False(ctx.SubscriptionB.Events.TryRead(out _)); + Assert.False(ctx.TenantContext.HasTenant); + } + + // ─── Цикл: сбой pump тенанта не роняет проход ────────────────────────── + + [Fact] + public async Task RunCycle_TenantPumpFailure_DoesNotAbortOtherTenants() + { + // Pump тенанта A падает на чтении очереди (имитация сбоя схемы/БД) — A логируется, B обрабатывается. + Context ctx = CreateContext(withThrowingQueueReadA: true); + ctx.PipelineA.SeedQueue(QueueRow("p_a_1", VacancyText, "d_a")); + ctx.PipelineB.SeedQueue(QueueRow("p_b_1", VacancyText, "d_b")); + + await ctx.Scheduler.RunCycleAsync(CancellationToken.None); + + // Pump A не удался (лог планировщика) — строка A осталась, карточки A нет; B обработан штатно. + Assert.Single(ctx.PipelineA.Queue); + Assert.Empty(ctx.KanjA.CardDtos); + Assert.False(ctx.SubscriptionA.Events.TryRead(out _)); + Assert.Contains(ctx.Logs.Messages, message => message.Contains("pump тенанта") && message.Contains("ListAsync")); + Assert.Empty(ctx.PipelineB.Queue); + CardDto cardB = Assert.Single(ctx.KanjB.CardDtos); + Assert.Equal([cardB.Id], ReadNewLeadIds(ctx.SubscriptionB)); + Assert.False(ctx.TenantContext.HasTenant); + } + + // ─── Цикл: общий гейт с admin/tick ───────────────────────────────────── + + [Fact] + public async Task RunCycle_GateBusyForTenant_SkipsPumpAndKeepsQueue() + { + Context ctx = CreateContext(); + ctx.PipelineA.SeedQueue(QueueRow("p_a_1", VacancyText, "d_a")); + ctx.PipelineB.SeedQueue(QueueRow("p_b_1", VacancyText, "d_b")); + + // Очередь тенанта A уже разбирает другой воркер (ручной POST /api/admin/tick): гейт занят — цикл + // пропускает A (как прототип L901–902: занятый lock → {}), очередь ждёт следующего срабатывания. + Assert.True(ctx.PumpGate.TryEnter(TenantA)); + await ctx.Scheduler.RunCycleAsync(CancellationToken.None); + + Assert.Single(ctx.PipelineA.Queue); + Assert.Empty(ctx.KanjA.CardDtos); + Assert.False(ctx.SubscriptionA.Events.TryRead(out _)); + Assert.Empty(ctx.PipelineB.Queue); + CardDto cardB = Assert.Single(ctx.KanjB.CardDtos); + Assert.Equal([cardB.Id], ReadNewLeadIds(ctx.SubscriptionB)); + + // Гейт A остался за ручным тиком (цикл его не освобождал — не захватывал); после тика гейт свободен. + Assert.False(ctx.PumpGate.TryEnter(TenantA)); + ctx.PumpGate.Exit(TenantA); + Assert.True(ctx.PumpGate.TryEnter(TenantA)); + ctx.PumpGate.Exit(TenantA); + Assert.False(ctx.TenantContext.HasTenant); + } + + // ─── Контекст и хелперы ───────────────────────────────────────────────── + + // Текст вакансии-сценария: проходит правила, дедуп, ML «спит» → ИИ-путь → карточка inbox. + private const string VacancyText = "Вакансия: Python-разработчик в команду, удалённая работа, оплата 2000$ в месяц"; + + // Собирает планировщик на реальных сервисах модуля Pipeline и тенант-фейках (без таймера). + // withThrowingQueueReadA: true — чтение очереди тенанта A бросает (сценарий сбоя pump). + // Возвращает: Планировщик, фейки очередей/канбана тенантов, каналы подписок и гейт. + private static Context CreateContext(bool withThrowingQueueReadA = false) + { + var tenants = new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)); + var tenantContext = new TenantContext(); + FakePipelineStore pipelineA = withThrowingQueueReadA ? new ThrowingQueueReadPipelineStore() : new FakePipelineStore(); + var pipelineB = new FakePipelineStore(); + var kanjA = new FakeKanjStore(); + var kanjB = new FakeKanjStore(); + var settingsA = new FakeSettingsStore(); + var settingsB = new FakeSettingsStore(); + var pumpGate = new PipelinePumpGate(); + var aiClassifier = new FakeAiClassifier + { + ClassifyResult = Parsed("Python-разработчик в команду", isVacancy: true), + }; + + var services = new ServiceCollection(); + services.AddSingleton(tenantContext); + services.AddSingleton(); + services.AddSingleton(tenants); + services.AddSingleton(pumpGate); + services.AddSingleton(new FakeMlClient()); + services.AddSingleton(aiClassifier); + // Тенант-scoped адаптеры: фейк выбирает хранилище по ITenantContext, который цикл заполняет SetTenant + // (эталон StorageTickSchedulerTests/ConnectionStringProvider.ForTenant). + services.AddScoped(provider => TenantOf(provider) == TenantA ? kanjA : kanjB); + services.AddScoped(provider => TenantOf(provider) == TenantA ? settingsA : settingsB); + services.AddScoped(provider => TenantOf(provider) == TenantA ? pipelineA : pipelineB); + // Реальные сервисы модуля Pipeline — как AddPipelineModule в Program.cs: цикл резолвит их в tenant-scope. + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + + ServiceProvider provider = services.BuildServiceProvider(); + SseBroker broker = provider.GetRequiredService(); + var logs = new ListLogger(); + var scheduler = new PipelineWorkerScheduler( + provider.GetRequiredService(), + pumpGate, + broker, + logs); + + return new Context( + scheduler, + pipelineA, + kanjA, + broker.Subscribe(TenantA), + pipelineB, + kanjB, + broker.Subscribe(TenantB), + pumpGate, + tenantContext, + logs); + } + + // Id текущего тенанта из контекста (резолвер фейков, как ConnectionStringProvider.ForTenant). + // provider: Scope, в котором выполняется pump тенанта. + // Возвращает: Guid тенанта из TenantId (формат N). + private static Guid TenantOf(IServiceProvider provider) + { + TenantId tenantId = provider.GetRequiredService().TenantId + ?? throw new InvalidOperationException("Тест: pump вне tenant-контекста (SetTenant не выполнен)"); + return Guid.Parse(tenantId.Value); + } + + // Запись реестра тенанта (как строка public.tenants). + // id: Идентификатор тенанта. + // Возвращает: Запись тенанта со статусом active. + private static TenantRecordDto Tenant(Guid id) => + new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow); + + // Строка очереди (status new; сценарий перекрывает текст/время — сейчас, stale не сработает). + private static QueueItemDto QueueRow(string id, string text, string dialogId) => new() + { + Id = id, + DialogId = dialogId, + MsgId = 100, + Text = text, + Status = PipelineQueueStatuses.New, + Channel = new PipelineChannelDto("Канал", "channel", "#333"), + MsgAtMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(), + QueuedAtMs = 1_700_000_000_000, + }; + + // Минимальный разбор карточки (карточку домыслит CardComposer; колонка не назначена → inbox). + private static AiParsedCardDto Parsed(string title, bool isVacancy = false) => new( + title, + Company: null, + Format: null, + Task: null, + Requirements: null, + Plus: null, + Conditions: null, + Summary: null, + Stack: Array.Empty(), + Budget: null, + Contacts: Array.Empty(), + isVacancy, + IsVacancyKnown: false, + IsSpam: false, + Board: null); + + // Id карточек из new_card-событий канала в порядке публикации. + // subscription: Подписка канала тенанта. + // Возвращает: Id созданных карточек (поле id payload'а new_card). + private static List ReadNewLeadIds(SseSubscription subscription) + { + var cardIds = new List(); + while (subscription.Events.TryRead(out SseEvent? sseEvent)) + { + Assert.Equal("new_card", sseEvent!.Type); + using JsonDocument payload = JsonDocument.Parse(sseEvent.Json); + cardIds.Add(payload.RootElement.GetProperty("id").GetString()!); + } + + return cardIds; + } + + // Хранилище со сбоем чтения очереди: ListAsync бросает (сценарий «БД/схема недоступны» на pump). + private sealed class ThrowingQueueReadPipelineStore : FakePipelineStore + { + /// + public override Task> ListAsync(string? status, int limit, CancellationToken ct) + { + throw new InvalidOperationException("Тестовый сбой чтения очереди (ListAsync)."); + } + } + + // Логгер-коллектор: копит сообщения планировщика (диагностика в тестах сбоя pump). + private sealed class ListLogger : ILogger + { + /// + /// Отформатированные сообщения лога в порядке записи. + /// + public List Messages { get; } = []; + + /// + public IDisposable? BeginScope(TState state) where TState : notnull => null; + + /// + public bool IsEnabled(LogLevel logLevel) => true; + + /// + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + Messages.Add(exception is null ? formatter(state, exception) : formatter(state, exception) + " :: " + exception); + } + } +} diff --git a/src/core/tests/Deal.Tests.Unit/PipelineWorkerServiceTests.cs b/src/core/tests/Deal.Tests.Unit/PipelineWorkerServiceTests.cs index 8d75722..5235550 100644 --- a/src/core/tests/Deal.Tests.Unit/PipelineWorkerServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/PipelineWorkerServiceTests.cs @@ -1,11 +1,19 @@ using System.Text.Json; using Deal.Contracts.Integrations.Models; -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; using Deal.Modules.Pipeline.Application.Parse; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/PromptDefaultsTests.cs b/src/core/tests/Deal.Tests.Unit/PromptDefaultsTests.cs index 16ec161..a6baa70 100644 --- a/src/core/tests/Deal.Tests.Unit/PromptDefaultsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/PromptDefaultsTests.cs @@ -1,4 +1,7 @@ -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/RateLimitHttpTests.cs b/src/core/tests/Deal.Tests.Unit/RateLimitHttpTests.cs index 5b56a3b..6e49ecf 100644 --- a/src/core/tests/Deal.Tests.Unit/RateLimitHttpTests.cs +++ b/src/core/tests/Deal.Tests.Unit/RateLimitHttpTests.cs @@ -3,7 +3,11 @@ using System.Text.Json; using Deal.Api.Configuration; using Deal.Api.Http; using Deal.Api.Middleware; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.Hosting; using Microsoft.AspNetCore.Http; diff --git a/src/core/tests/Deal.Tests.Unit/RatesServiceTests.cs b/src/core/tests/Deal.Tests.Unit/RatesServiceTests.cs index 6526ad9..6333a1a 100644 --- a/src/core/tests/Deal.Tests.Unit/RatesServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/RatesServiceTests.cs @@ -1,6 +1,8 @@ using System.Text.Json; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/RuntimeDepthsCollectorTests.cs b/src/core/tests/Deal.Tests.Unit/RuntimeDepthsCollectorTests.cs index b346d43..8d572f6 100644 --- a/src/core/tests/Deal.Tests.Unit/RuntimeDepthsCollectorTests.cs +++ b/src/core/tests/Deal.Tests.Unit/RuntimeDepthsCollectorTests.cs @@ -1,112 +1,121 @@ -using Deal.Api.Observability; -using Deal.Contracts.Integrations; -using Deal.Infrastructure.Data; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Pipeline.Application; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Tenants; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Tests.Unit; - -/// -/// Тесты сборщика глубин очередей/сессий (§10.2): агрегат по тенантам через существующие сервисы. -/// -/// -/// DealDbContext в хосте не регистрируется: секция активных сессий ловит сбой и отдаёт 0 — так тест -/// фокусируется на агрегации очередей пайплайна (PipelineProcessingService на FakePipelineStore) и -/// MlOutbox (FakeMlLearningStore) по двум тенантам, не поднимая Postgres. -/// -public sealed class RuntimeDepthsCollectorTests -{ - private static readonly Guid TenantA = Guid.Parse("11111111-1111-1111-1111-111111111111"); - private static readonly Guid TenantB = Guid.Parse("22222222-2222-2222-2222-222222222222"); - - [Fact] - public async Task CollectAsync_SumsQueueAndOutboxAcrossTenants() - { - var pipelineByTenant = new Dictionary - { - [TenantA.ToString("N")] = new FakePipelineStore(), - [TenantB.ToString("N")] = new FakePipelineStore(), - }; - pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a1")); - pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a2")); - pipelineByTenant[TenantB.ToString("N")].SeedQueue(QueueItem("p_b1")); - - var outboxByTenant = new Dictionary - { - [TenantA.ToString("N")] = new FakeMlLearningStore(), - [TenantB.ToString("N")] = new FakeMlLearningStore(), - }; - outboxByTenant[TenantA.ToString("N")].SeedOutbox("mle_1", "текст", "spam"); - - RuntimeDepthsCollector collector = Build(pipelineByTenant, outboxByTenant); - - RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None); - - Assert.Equal(3, depths.PipelineQueue); - Assert.Equal(1, depths.MlOutbox); - Assert.Equal(0, depths.ActiveSessions); - } - - [Fact] - public async Task CollectAsync_NoTenants_ReturnsZeros() - { - RuntimeDepthsCollector collector = Build( - new Dictionary(), - new Dictionary(), - tenants: Array.Empty()); - - RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None); - - Assert.Equal(0, depths.PipelineQueue); - Assert.Equal(0, depths.MlOutbox); - Assert.Equal(0, depths.ActiveSessions); - } - - private static QueueItemDto QueueItem(string id) => new() - { - Id = id, - DialogId = "d_1", - Text = "текст", - Status = PipelineQueueStatuses.New, - }; - - // Собирает коллектор поверх tenant-scoped фейков (как реальные адаптеры по ITenantContext). - private static RuntimeDepthsCollector Build( - IReadOnlyDictionary pipelineByTenant, - IReadOnlyDictionary outboxByTenant, - IReadOnlyList? tenants = null) - { - var services = new ServiceCollection(); - services.AddSingleton(); - services.AddSingleton(new FakeTenantRepository( - (tenants ?? new[] - { - new TenantRecordDto(TenantA, "A", "active", DateTimeOffset.UtcNow), - new TenantRecordDto(TenantB, "B", "active", DateTimeOffset.UtcNow), - }).ToArray())); - - services.AddScoped(provider => pipelineByTenant[CurrentTenant(provider)]); - services.AddScoped(_ => new FakeMlClient()); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(provider => outboxByTenant[CurrentTenant(provider)]); - - ServiceProvider provider = services.BuildServiceProvider(); - return new RuntimeDepthsCollector( - provider.GetRequiredService(), - NullLogger.Instance); - } - - // Текущий tenant-id контекста (формат N — как схема tenant_<N>). - private static string CurrentTenant(IServiceProvider provider) - { - ITenantContext context = provider.GetRequiredService(); - return context.TenantId!.Value.Value; - } -} +using Deal.Api.Observability; +using Deal.Contracts.Integrations; +using Deal.Infrastructure.Data; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; +using Deal.SharedKernel.Tenants; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Tests.Unit; + +/// +/// Тесты сборщика глубин очередей/сессий (§10.2): агрегат по тенантам через существующие сервисы. +/// +/// +/// DealDbContext в хосте не регистрируется: секция активных сессий ловит сбой и отдаёт 0 — так тест +/// фокусируется на агрегации очередей пайплайна (PipelineProcessingService на FakePipelineStore) и +/// MlOutbox (FakeMlLearningStore) по двум тенантам, не поднимая Postgres. +/// +public sealed class RuntimeDepthsCollectorTests +{ + private static readonly Guid TenantA = Guid.Parse("11111111-1111-1111-1111-111111111111"); + private static readonly Guid TenantB = Guid.Parse("22222222-2222-2222-2222-222222222222"); + + [Fact] + public async Task CollectAsync_SumsQueueAndOutboxAcrossTenants() + { + var pipelineByTenant = new Dictionary + { + [TenantA.ToString("N")] = new FakePipelineStore(), + [TenantB.ToString("N")] = new FakePipelineStore(), + }; + pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a1")); + pipelineByTenant[TenantA.ToString("N")].SeedQueue(QueueItem("p_a2")); + pipelineByTenant[TenantB.ToString("N")].SeedQueue(QueueItem("p_b1")); + + var outboxByTenant = new Dictionary + { + [TenantA.ToString("N")] = new FakeMlLearningStore(), + [TenantB.ToString("N")] = new FakeMlLearningStore(), + }; + outboxByTenant[TenantA.ToString("N")].SeedOutbox("mle_1", "текст", "spam"); + + RuntimeDepthsCollector collector = Build(pipelineByTenant, outboxByTenant); + + RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None); + + Assert.Equal(3, depths.PipelineQueue); + Assert.Equal(1, depths.MlOutbox); + Assert.Equal(0, depths.ActiveSessions); + } + + [Fact] + public async Task CollectAsync_NoTenants_ReturnsZeros() + { + RuntimeDepthsCollector collector = Build( + new Dictionary(), + new Dictionary(), + tenants: Array.Empty()); + + RuntimeDepthsDto depths = await collector.CollectAsync(CancellationToken.None); + + Assert.Equal(0, depths.PipelineQueue); + Assert.Equal(0, depths.MlOutbox); + Assert.Equal(0, depths.ActiveSessions); + } + + private static QueueItemDto QueueItem(string id) => new() + { + Id = id, + DialogId = "d_1", + Text = "текст", + Status = PipelineQueueStatuses.New, + }; + + // Собирает коллектор поверх tenant-scoped фейков (как реальные адаптеры по ITenantContext). + private static RuntimeDepthsCollector Build( + IReadOnlyDictionary pipelineByTenant, + IReadOnlyDictionary outboxByTenant, + IReadOnlyList? tenants = null) + { + var services = new ServiceCollection(); + services.AddSingleton(); + services.AddSingleton(new FakeTenantRepository( + (tenants ?? new[] + { + new TenantRecordDto(TenantA, "A", "active", DateTimeOffset.UtcNow), + new TenantRecordDto(TenantB, "B", "active", DateTimeOffset.UtcNow), + }).ToArray())); + + services.AddScoped(provider => pipelineByTenant[CurrentTenant(provider)]); + services.AddScoped(_ => new FakeMlClient()); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(provider => outboxByTenant[CurrentTenant(provider)]); + + ServiceProvider provider = services.BuildServiceProvider(); + return new RuntimeDepthsCollector( + provider.GetRequiredService(), + NullLogger.Instance); + } + + // Текущий tenant-id контекста (формат N — как схема tenant_<N>). + private static string CurrentTenant(IServiceProvider provider) + { + ITenantContext context = provider.GetRequiredService(); + return context.TenantId!.Value.Value; + } +} diff --git a/src/core/tests/Deal.Tests.Unit/SecretCipherTests.cs b/src/core/tests/Deal.Tests.Unit/SecretCipherTests.cs index b54d4a2..dd5af9c 100644 --- a/src/core/tests/Deal.Tests.Unit/SecretCipherTests.cs +++ b/src/core/tests/Deal.Tests.Unit/SecretCipherTests.cs @@ -1,5 +1,8 @@ using Deal.Infrastructure.Security; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/SessionTokensTests.cs b/src/core/tests/Deal.Tests.Unit/SessionTokensTests.cs index b5a5c1a..2fa8489 100644 --- a/src/core/tests/Deal.Tests.Unit/SessionTokensTests.cs +++ b/src/core/tests/Deal.Tests.Unit/SessionTokensTests.cs @@ -1,4 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/SettingsCatalogTests.cs b/src/core/tests/Deal.Tests.Unit/SettingsCatalogTests.cs index e87ff16..2f54923 100644 --- a/src/core/tests/Deal.Tests.Unit/SettingsCatalogTests.cs +++ b/src/core/tests/Deal.Tests.Unit/SettingsCatalogTests.cs @@ -1,5 +1,7 @@ -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/SettingsServiceTests.cs b/src/core/tests/Deal.Tests.Unit/SettingsServiceTests.cs index ad2d2c6..102f34b 100644 --- a/src/core/tests/Deal.Tests.Unit/SettingsServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/SettingsServiceTests.cs @@ -1,6 +1,8 @@ using System.Text.Json; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/StorageTickSchedulerTests.cs b/src/core/tests/Deal.Tests.Unit/StorageTickSchedulerTests.cs index 4743898..5ab2f4f 100644 --- a/src/core/tests/Deal.Tests.Unit/StorageTickSchedulerTests.cs +++ b/src/core/tests/Deal.Tests.Unit/StorageTickSchedulerTests.cs @@ -1,514 +1,524 @@ -using System.Text.Json; -using Deal.Api.Events; -using Deal.Api.Hosting; -using Deal.Contracts.Integrations; -using Deal.Infrastructure.Data; -using Deal.Modules.Kanban.Application; -using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Pipeline.Application; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Settings.Application; -using Deal.Modules.Settings.Application.Models; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; -using Deal.SharedKernel.Tenants; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Tests.Unit; - -/// -/// Тесты StorageTickScheduler — логика прохода цикла правил хранения (план Task 11, Ruling 8; -/// аналог _storage_loop main.py L43–53): обход всех тенантов реестра, на каждый — собственный scope с -/// ITenantContext, тик StorageTickService + автоочистка отсева пайплайна (3 суток, Task 11), SSE-тосты -/// статистики (включая «Отсев очищен») в канал тенанта и проверка наступивших напоминаний «Отложено» -/// (CheckDueAsync + SSE reminder_due, план Task 12, Ruling 3/8 — фоновый аналог ветки AdminTickOrchestrator). -/// -/// -/// Тайминги цикла (Timer 30 с, первый проход, stop) не тестируются — тестируется итерация через -/// публичный . Скоупы/DI поднимаются на реальном -/// ServiceCollection с фейками: ITenantRepository — FakeTenantRepository; ICardStore/ISettingsStore — выбираются -/// по текущему ITenantContext (как реальные адаптеры, строящие TenantDbContext от схемы тенанта), -/// StorageTickService/CardsService — реальные на фейках. -/// Публикации проверяются реальным SseBroker с подпиской канала -/// (как в тестах тостов). Возраст карточек задаётся с запасом к дефолтным срокам SettingsDefaults -/// (autoArchive=true, archiveAfterDays=14) — как в StorageTickServiceTests. -/// -public sealed class StorageTickSchedulerTests -{ - // Тенант A теста (канал подписки). - private static readonly Guid TenantA = Guid.NewGuid(); - - // Тенант B теста (канал подписки). - private static readonly Guid TenantB = Guid.NewGuid(); - - // Возраст карточек-кандидатов автоархива: запас к дефолту archiveAfterDays=14 (SettingsDefaults). - private static readonly TimeSpan ExpiredAge = TimeSpan.FromDays(20); - - [Fact] - public async Task RunCycle_TicksEveryTenantInOwnScopeAndPublishesToastToEachTenantChannel() - { - FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old"); - FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old"); - var settings = new FakeSettingsStore(); - var tenants = new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)); - var tenantContext = new TenantContext(); - await using ServiceProvider provider = BuildProvider(tenants, tenantContext, storeA, storeB, settings); - - SseBroker broker = provider.GetRequiredService(); - SseSubscription subscriptionA = broker.Subscribe(TenantA); - SseSubscription subscriptionB = broker.Subscribe(TenantB); - StorageTickScheduler scheduler = CreateScheduler(provider); - - await scheduler.RunCycleAsync(CancellationToken.None); - - // Каждый тенант оттикал в собственном scope (карточка ушла в archive) и получил свой тост. - Assert.Equal(KanbanColumns.Archive, storeA.CardDtos.Single(card => card.Id == "l_a_old").Col); - Assert.Equal(KanbanColumns.Archive, storeB.CardDtos.Single(card => card.Id == "l_b_old").Col); - (string Text, string Icon)[] autoArchiveToast = [(Text: "Автоархив: 1 карточек", Icon: "clock")]; - Assert.Equal(autoArchiveToast, ReadToasts(subscriptionA)); - Assert.Equal(autoArchiveToast, ReadToasts(subscriptionB)); - - // Контекст AsyncLocal не должен переживать проход (Reset в finally каждого тика). - Assert.False(tenantContext.HasTenant); - } - - [Fact] - public async Task RunCycle_ZeroCountersTenant_PublishesToastOnlyToTenantWithChanges() - { - FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old"); - var storeB = new FakeKanjStore(); - storeB.SeedCard(Card("l_b_fresh", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromHours(1)))); - var settings = new FakeSettingsStore(); - var tenantContext = new TenantContext(); - await using ServiceProvider provider = BuildProvider( - new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), - tenantContext, - storeA, - storeB, - settings); - - SseBroker broker = provider.GetRequiredService(); - SseSubscription subscriptionA = broker.Subscribe(TenantA); - SseSubscription subscriptionB = broker.Subscribe(TenantB); - StorageTickScheduler scheduler = CreateScheduler(provider); - - await scheduler.RunCycleAsync(CancellationToken.None); - - // Тенант B не архивировал (свежая карточка) — тост в его канал не публикуется. - Assert.Single(ReadToasts(subscriptionA)); - Assert.False(subscriptionB.Events.TryRead(out _)); - Assert.False(tenantContext.HasTenant); - } - - [Fact] - public async Task RunCycle_TenantTickFailure_DoesNotAbortOtherTenants() - { - // У тенанта A настройки падают (имитация сбоя схемы/БД) — тик A логирует ошибку, B обрабатывается. - FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old"); - var tenantContext = new TenantContext(); - var settingsByTenant = new Dictionary - { - [TenantA] = new ThrowingSettingsStore(), - [TenantB] = new FakeSettingsStore(), - }; - var storesByTenant = new Dictionary - { - [TenantA] = new FakeKanjStore(), - [TenantB] = storeB, - }; - await using ServiceProvider provider = BuildProvider( - new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), - tenantContext, - storesByTenant, - settingsByTenant); - - SseBroker broker = provider.GetRequiredService(); - SseSubscription subscriptionA = broker.Subscribe(TenantA); - SseSubscription subscriptionB = broker.Subscribe(TenantB); - StorageTickScheduler scheduler = CreateScheduler(provider); - - await scheduler.RunCycleAsync(CancellationToken.None); - - Assert.False(subscriptionA.Events.TryRead(out _)); - (string Text, string Icon)[] autoArchiveToast = [(Text: "Автоархив: 1 карточек", Icon: "clock")]; - Assert.Equal(autoArchiveToast, ReadToasts(subscriptionB)); - Assert.False(tenantContext.HasTenant); - } - - [Fact] - public async Task RunCycle_TenantListFailure_DoesNotThrow() - { - var tenantContext = new TenantContext(); - await using ServiceProvider provider = BuildProvider( - new ThrowingTenantRepository(), - tenantContext, - new Dictionary(), - new Dictionary()); - StorageTickScheduler scheduler = CreateScheduler(provider); - - // Сбой реестра логируется внутри цикла и не выбрасывается наружу (цикл живёт дальше). - await scheduler.RunCycleAsync(CancellationToken.None); - - Assert.False(tenantContext.HasTenant); - } - - [Fact] - public async Task RunCycle_PurgesExpiredRejectedRowsAndPublishesRejectedPurgeToast() - { - var kanjStore = new FakeKanjStore(); - var settings = new FakeSettingsStore(); - var tenantContext = new TenantContext(); - var pipelineStoreA = new FakePipelineStore(); - long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - pipelineStoreA.SeedRejected(Rejected("r_old_a", (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds))); - pipelineStoreA.SeedRejected(Rejected("r_fresh", (long)(nowMs - TimeSpan.FromDays(1).TotalMilliseconds))); - await using ServiceProvider provider = BuildProvider( - new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), - tenantContext, - new Dictionary { [TenantA] = kanjStore, [TenantB] = new FakeKanjStore() }, - new Dictionary { [TenantA] = settings, [TenantB] = settings }, - new Dictionary { [TenantA] = pipelineStoreA, [TenantB] = new FakePipelineStore() }); - - SseBroker broker = provider.GetRequiredService(); - SseSubscription subscriptionA = broker.Subscribe(TenantA); - SseSubscription subscriptionB = broker.Subscribe(TenantB); - StorageTickScheduler scheduler = CreateScheduler(provider); - - await scheduler.RunCycleAsync(CancellationToken.None); - - // Автоочистка отсева в фоновом тике (Task 11, Ruling 8/9; tick_storage L485–493): запись старше - // 3 суток удалена безвозвратно, свежая пережила; тост «Отсев очищен» — только в канал тенанта A - // (у B счётчик purge = 0 — тоста нет, как notify_tick_stats L503–504). - Assert.Equal("r_fresh", Assert.Single(pipelineStoreA.Rejected).Id); - (string Text, string Icon)[] rejectedPurgeToast = [(Text: "Отсев очищен: 1 записей (3 дн.)", Icon: "trash")]; - Assert.Equal(rejectedPurgeToast, ReadToasts(subscriptionA)); - Assert.False(subscriptionB.Events.TryRead(out _)); - Assert.False(tenantContext.HasTenant); - } - - [Fact] - public async Task RunCycle_DueReminder_PublishesReminderDueToTenantChannelAndMarksFired() - { - // Тенант A: hold-карточка с напоминанием в прошлом («выстреливает») и в будущем + не-hold с прошлым - // (не «выстреливают», как check_reminders L270–275); тенант B — без due (событий в его канал нет). - var cardStoreA = new FakeKanjStore(); - cardStoreA.SeedCard(HoldCard("c_a_past", title: "Отложенный бот", reminderAtMs: NowMs() - 60_000)); - 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 FakeKanjStore(); - var tenantContext = new TenantContext(); - await using ServiceProvider provider = BuildProvider( - new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), - tenantContext, - new Dictionary { [TenantA] = cardStoreA, [TenantB] = cardStoreB }, - new Dictionary { [TenantA] = new FakeSettingsStore(), [TenantB] = new FakeSettingsStore() }, - new Dictionary { [TenantA] = new(), [TenantB] = new() }); - - SseBroker broker = provider.GetRequiredService(); - SseSubscription subscriptionA = broker.Subscribe(TenantA); - SseSubscription subscriptionB = broker.Subscribe(TenantB); - StorageTickScheduler scheduler = CreateScheduler(provider); - - await scheduler.RunCycleAsync(CancellationToken.None); - - // SSE reminder_due {id,title,containerId} по «выстрелившему» (Ruling 8; toast НЕ публикуется — счётчики - // тика нулевые), в канал только тенанта A (у B due нет). - (string Type, string Json) dueEvent = Assert.Single(ReadEvents(subscriptionA)); - Assert.Equal("reminder_due", dueEvent.Type); - using JsonDocument payload = JsonDocument.Parse(dueEvent.Json); - Assert.Equal("c_a_past", payload.RootElement.GetProperty("id").GetString()); - Assert.Equal("Отложенный бот", payload.RootElement.GetProperty("title").GetString()); - Assert.Equal("hold", payload.RootElement.GetProperty("containerId").GetString()); - Assert.False(subscriptionB.Events.TryRead(out _)); - - // «Выстрелившее» помечено fired: повторная выборка due пуста (признак держит строка БД). - Assert.Empty(await cardStoreA.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None)); - Assert.False(tenantContext.HasTenant); - } - - [Fact] - public async Task RunCycle_RemindersDisabled_ClearsExpiredAndPublishesNoReminderDue() - { - // Выключенная настройка (Ruling 3): протухшие напоминания только очищаются, «выстрелов»/событий нет - // (check_reminders L266–269) — фон ведёт себя как ручная ветка AdminTickOrchestrator. - var cardStoreA = new FakeKanjStore(); - cardStoreA.SeedCard(HoldCard("c_past", title: "Старое", reminderAtMs: NowMs() - 60_000)); - var settingsA = new FakeSettingsStore(); - settingsA.Preload(SettingsKeys.RemindersEnabled, "false"); - var tenantContext = new TenantContext(); - await using ServiceProvider provider = BuildProvider( - new FakeTenantRepository(Tenant(TenantA)), - tenantContext, - new Dictionary { [TenantA] = cardStoreA }, - new Dictionary { [TenantA] = settingsA }, - new Dictionary { [TenantA] = new() }); - - SseBroker broker = provider.GetRequiredService(); - SseSubscription subscriptionA = broker.Subscribe(TenantA); - StorageTickScheduler scheduler = CreateScheduler(provider); - - await scheduler.RunCycleAsync(CancellationToken.None); - - Assert.False(subscriptionA.Events.TryRead(out _)); // ни reminder_due, ни тостов (счётчики нулевые) - Assert.Null(Assert.Single(cardStoreA.CardDtos).Reminder); // протухшее очищено (L268) - Assert.False(tenantContext.HasTenant); - } - - [Fact] - public async Task RunCycle_ReminderCheckFailure_DoesNotAbortTenantTickOrOtherTenants() - { - // У тенанта A проверка напоминаний падает (имитация сбоя схемы/БД на ListDueAsync) — ветка логируется - // и тик A завершается, тенант B обрабатывается (автоархив + тост) — проход жив. - var cardStoreA = new ThrowingDueKanjStore(); - FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old"); - var tenantContext = new TenantContext(); - await using ServiceProvider provider = BuildProvider( - new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), - tenantContext, - new Dictionary { [TenantA] = cardStoreA, [TenantB] = storeB }, - new Dictionary { [TenantA] = new FakeSettingsStore(), [TenantB] = new FakeSettingsStore() }, - new Dictionary { [TenantA] = new(), [TenantB] = new() }); - - SseBroker broker = provider.GetRequiredService(); - SseSubscription subscriptionA = broker.Subscribe(TenantA); - SseSubscription subscriptionB = broker.Subscribe(TenantB); - StorageTickScheduler scheduler = CreateScheduler(provider); - - await scheduler.RunCycleAsync(CancellationToken.None); - - Assert.False(subscriptionA.Events.TryRead(out _)); // сбой ветки — событий у A нет, тик A не «упал» - Assert.Equal(KanbanColumns.Archive, storeB.CardDtos.Single(card => card.Id == "l_b_old").Col); - (string Text, string Icon)[] autoArchiveToast = [(Text: "Автоархив: 1 карточек", Icon: "clock")]; - Assert.Equal(autoArchiveToast, ReadToasts(subscriptionB)); - Assert.False(tenantContext.HasTenant); - } - - // ─── Хелперы ──────────────────────────────────────────────────────────── - - // Строит DI-провайдер теста: системный реестр + тенант-зависимые фейки по ITenantContext. - // tenants: Фейк реестра тенантов (системный scope прохода). - // tenantContext: Реальный контекст тенанта (AsyncLocal, как в приложении). - // storeA: Хранилище канбана тенанта A. - // storeB: Хранилище канбана тенанта B. - // settings: Настройки обоих тенантов (пустые — дефолты тика). - // Возвращает: Провайдер с зарегистрированными сервисами теста. - private static ServiceProvider BuildProvider( - FakeTenantRepository tenants, - TenantContext tenantContext, - FakeKanjStore storeA, - FakeKanjStore storeB, - FakeSettingsStore settings) - { - return BuildProvider( - tenants, - tenantContext, - new Dictionary { [TenantA] = storeA, [TenantB] = storeB }, - new Dictionary { [TenantA] = settings, [TenantB] = settings }); - } - - // Строит DI-провайдер теста по явным картам тенант → хранилище/настройки. - // tenants: Фейк реестра тенантов. - // tenantContext: Реальный контекст тенанта (AsyncLocal). - // storesByTenant: Хранилища канбана по тенантам (резолвятся по текущему контексту). - // settingsByTenant: Хранилища настроек по тенантам. - // pipelineStoresByTenant: Хранилища пайплайна по тенантам (очистка отсева тика); null — пустые. - // Возвращает: Провайдер с зарегистрированными сервисами теста. - private static ServiceProvider BuildProvider( - ITenantRepository tenants, - TenantContext tenantContext, - Dictionary storesByTenant, - Dictionary settingsByTenant, - Dictionary? pipelineStoresByTenant = null) - { - pipelineStoresByTenant ??= new Dictionary - { - [TenantA] = new FakePipelineStore(), - [TenantB] = new FakePipelineStore(), - }; - - var services = new ServiceCollection(); - services.AddSingleton(tenantContext); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(tenants); - // Тенант-scoped адаптеры: реальные строят TenantDbContext по схеме текущего тенанта — фейк - // выбирает хранилище по тому же ITenantContext, который планировщик заполняет SetTenant. - services.AddScoped(provider => storesByTenant[TenantOf(provider)]); - services.AddScoped(provider => settingsByTenant[TenantOf(provider)]); - services.AddScoped(provider => pipelineStoresByTenant[TenantOf(provider)]); - // Модуль Pipeline для автоочистки отсева в фоновом тике (Task 11): PurgeExpiredAsync ходит только в - // IPipelineStore; ML-клиент не готов (как в проде этапа 4) — обработка его при purge не зовёт. - services.AddSingleton(new FakeMlClient()); - services.AddSingleton(new FakeFileStorage()); - services.AddScoped(); - services.AddScoped(); - services.AddScoped(); - // Проверка напоминаний фонового тика (Task 12) — реальный CardsService на ICardStore/FakeSettingsStore - // тенанта (как в AdminTickOrchestratorTests); сбой ветки имитируется подклассом FakeKanjStore с - // падающим ListDueRemindersAsync. - services.AddScoped(); - return services.BuildServiceProvider(); - } - - // Создаёт планировщик на провайдере теста (без StartAsync — таймер 30 с не заводим). - // provider: DI-провайдер с сервисами цикла. - // Возвращает: Планировщик с NullLogger. - private static StorageTickScheduler CreateScheduler(ServiceProvider provider) - { - return new StorageTickScheduler( - provider.GetRequiredService(), - provider.GetRequiredService(), - provider.GetRequiredService(), - NullLogger.Instance); - } - - // Id текущего тенанта из контекста (резолвер фейков, как ConnectionStringProvider.ForTenant). - // provider: Scope, в котором выполняется тик тенанта. - // Возвращает: Guid тенанта из TenantId (формат N). - private static Guid TenantOf(IServiceProvider provider) - { - TenantId tenantId = provider.GetRequiredService().TenantId - ?? throw new InvalidOperationException("Тест: тик вне tenant-контекста (SetTenant не выполнен)"); - return Guid.Parse(tenantId.Value); - } - - // Запись реестра тенанта (как строка public.tenants). - // id: Идентификатор тенанта. - // Возвращает: Запись тенанта со статусом active. - private static TenantRecordDto Tenant(Guid id) => - new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow); - - // Хранилище карточек со сбоем выборки due-напоминаний: ListDueRemindersAsync бросает (сценарий - // «БД/схема недоступны» на проверке напоминаний — ветка логируется, тик тенанта/проход живы, как в - // AdminTickOrchestratorTests). - private sealed class ThrowingDueKanjStore : FakeKanjStore - { - /// - public override Task> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct) - { - throw new InvalidOperationException("Тестовый сбой выборки due-напоминаний (ListDueRemindersAsync)."); - } - } - - // Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем. - private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); - - // Карточка hold с напоминанием (как строка Cards после маппинга адаптера). - // id: Id карточки (c_...). - // title: Заголовок (ушёл в SSE reminder_due). - // reminderAtMs: Время напоминания, epoch-ms (прошлое — «выстрелит» на проходе). - // stage: Контейнер карточки (по умолчанию hold — её напоминания проверяет тик, Ruling 3). - // Возвращает: Карточка как DTO хранилища. - private static CardDto HoldCard(string id, string title, long reminderAtMs, string stage = "hold") - { - return new CardDto - { - Id = id, - Col = stage, - Title = title, - Reminder = new CardReminderDto(reminderAtMs), - CreatedAtMs = 1, - UpdatedAtMs = 1, - }; - } - - // Хранилище с просроченной карточкой «Неразобранного» — кандидатом автоархива дефолтного тика. - // cardId: Id карточки. - // Возвращает: Фейк-хранилище с одной старой карточкой inbox. - private static FakeKanjStore StoreWithExpiredInboxCard(string cardId) - { - var store = new FakeKanjStore(); - store.SeedCard(Card(cardId, KanbanColumns.Inbox, ReceivedAtMsAgo(ExpiredAge))); - return store; - } - - // Карточка в колонке с моментом получения (epoch-ms). - private static CardDto Card(string id, string col, long receivedAtMs) => - new() { Id = id, Col = col, ReceivedAtMs = receivedAtMs }; - - // Минимальная запись отсева для сидирования (purge смотрит только RejectedAtMs — 3 суток). - // id: Id записи. - // rejectedAtMs: Время отсева, epoch-ms. - // Возвращает: Запись отсева как строка БД (остальные поля пустые — не участвуют). - private static RejectedItemDto Rejected(string id, long rejectedAtMs) => new() - { - Id = id, - RejectedAtMs = rejectedAtMs, - }; - - // Epoch-ms момента «now − возраст» (ReceivedAt карточки-кандидата). - private static long ReceivedAtMsAgo(TimeSpan age) => DateTimeOffset.UtcNow.Subtract(age).ToUnixTimeMilliseconds(); - - // Читает опубликованные тосты канала: (text, icon) в порядке публикации. - // subscription: Подписка тенанта-получателя. - // Возвращает: Пары text/icon событий toast канала. - private static List<(string Text, string Icon)> ReadToasts(SseSubscription subscription) - { - var toasts = new List<(string Text, string Icon)>(); - while (subscription.Events.TryRead(out SseEvent? sseEvent)) - { - Assert.Equal("toast", sseEvent!.Type); - using JsonDocument payload = JsonDocument.Parse(sseEvent.Json); - toasts.Add(( - payload.RootElement.GetProperty("text").GetString()!, - payload.RootElement.GetProperty("icon").GetString()!)); - } - - return toasts; - } - - // Вычитывает все события канала: (тип, JSON) в порядке публикации (включая reminder_due). - // subscription: Подписка канала тенанта. - // Возвращает: События канала (toast/reminder_due) в порядке публикации. - private static List<(string Type, string Json)> ReadEvents(SseSubscription subscription) - { - var events = new List<(string Type, string Json)>(); - while (subscription.Events.TryRead(out SseEvent? sseEvent)) - { - events.Add((sseEvent!.Type, sseEvent.Json)); - } - - return events; - } - - // Реестр, у которого список тенантов падает (имитация недоступной системной БД). - private sealed class ThrowingTenantRepository : ITenantRepository - { - /// - public Task> ListAsync(CancellationToken ct) => - throw new InvalidOperationException("реестр тенантов недоступен"); - - /// - public Task FindByIdAsync(Guid id, CancellationToken ct) => - throw new NotSupportedException(); - - /// - public Task CreateAsync(TenantRecordDto tenant, CancellationToken ct) => - throw new NotSupportedException(); - - /// - public Task UpdateStatusAsync(Guid id, string status, CancellationToken ct) => - throw new NotSupportedException(); - } - - // Настройки, у которых чтение падает (имитация сбоя схемы/БД одного тенанта). - private sealed class ThrowingSettingsStore : ISettingsStore - { - /// - public Task GetAsync(string key, CancellationToken ct) => - throw new InvalidOperationException($"настройка {key} недоступна"); - - /// - public Task> GetAllAsync(CancellationToken ct) => - throw new NotSupportedException(); - - /// - public Task SetAsync(string key, string valueJson, CancellationToken ct) => - throw new NotSupportedException(); - - /// - public Task RemoveAsync(string key, CancellationToken ct) => - throw new NotSupportedException(); - } -} +using System.Text.Json; +using Deal.Api.Events; +using Deal.Api.Hosting; +using Deal.Contracts.Integrations; +using Deal.Infrastructure.Data; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; +using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; +using Deal.SharedKernel.Tenants; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Tests.Unit; + +/// +/// Тесты StorageTickScheduler — логика прохода цикла правил хранения (план Task 11, Ruling 8; +/// аналог _storage_loop main.py L43–53): обход всех тенантов реестра, на каждый — собственный scope с +/// ITenantContext, тик StorageTickService + автоочистка отсева пайплайна (3 суток, Task 11), SSE-тосты +/// статистики (включая «Отсев очищен») в канал тенанта и проверка наступивших напоминаний «Отложено» +/// (CheckDueAsync + SSE reminder_due, план Task 12, Ruling 3/8 — фоновый аналог ветки AdminTickOrchestrator). +/// +/// +/// Тайминги цикла (Timer 30 с, первый проход, stop) не тестируются — тестируется итерация через +/// публичный . Скоупы/DI поднимаются на реальном +/// ServiceCollection с фейками: ITenantRepository — FakeTenantRepository; ICardStore/ISettingsStore — выбираются +/// по текущему ITenantContext (как реальные адаптеры, строящие TenantDbContext от схемы тенанта), +/// StorageTickService/CardsService — реальные на фейках. +/// Публикации проверяются реальным SseBroker с подпиской канала +/// (как в тестах тостов). Возраст карточек задаётся с запасом к дефолтным срокам SettingsDefaults +/// (autoArchive=true, archiveAfterDays=14) — как в StorageTickServiceTests. +/// +public sealed class StorageTickSchedulerTests +{ + // Тенант A теста (канал подписки). + private static readonly Guid TenantA = Guid.NewGuid(); + + // Тенант B теста (канал подписки). + private static readonly Guid TenantB = Guid.NewGuid(); + + // Возраст карточек-кандидатов автоархива: запас к дефолту archiveAfterDays=14 (SettingsDefaults). + private static readonly TimeSpan ExpiredAge = TimeSpan.FromDays(20); + + [Fact] + public async Task RunCycle_TicksEveryTenantInOwnScopeAndPublishesToastToEachTenantChannel() + { + FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old"); + FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old"); + var settings = new FakeSettingsStore(); + var tenants = new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)); + var tenantContext = new TenantContext(); + await using ServiceProvider provider = BuildProvider(tenants, tenantContext, storeA, storeB, settings); + + SseBroker broker = provider.GetRequiredService(); + SseSubscription subscriptionA = broker.Subscribe(TenantA); + SseSubscription subscriptionB = broker.Subscribe(TenantB); + StorageTickScheduler scheduler = CreateScheduler(provider); + + await scheduler.RunCycleAsync(CancellationToken.None); + + // Каждый тенант оттикал в собственном scope (карточка ушла в archive) и получил свой тост. + Assert.Equal(KanbanColumns.Archive, storeA.CardDtos.Single(card => card.Id == "l_a_old").Col); + Assert.Equal(KanbanColumns.Archive, storeB.CardDtos.Single(card => card.Id == "l_b_old").Col); + (string Text, string Icon)[] autoArchiveToast = [(Text: "Автоархив: 1 карточек", Icon: "clock")]; + Assert.Equal(autoArchiveToast, ReadToasts(subscriptionA)); + Assert.Equal(autoArchiveToast, ReadToasts(subscriptionB)); + + // Контекст AsyncLocal не должен переживать проход (Reset в finally каждого тика). + Assert.False(tenantContext.HasTenant); + } + + [Fact] + public async Task RunCycle_ZeroCountersTenant_PublishesToastOnlyToTenantWithChanges() + { + FakeKanjStore storeA = StoreWithExpiredInboxCard("l_a_old"); + var storeB = new FakeKanjStore(); + storeB.SeedCard(Card("l_b_fresh", KanbanColumns.Inbox, ReceivedAtMsAgo(TimeSpan.FromHours(1)))); + var settings = new FakeSettingsStore(); + var tenantContext = new TenantContext(); + await using ServiceProvider provider = BuildProvider( + new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), + tenantContext, + storeA, + storeB, + settings); + + SseBroker broker = provider.GetRequiredService(); + SseSubscription subscriptionA = broker.Subscribe(TenantA); + SseSubscription subscriptionB = broker.Subscribe(TenantB); + StorageTickScheduler scheduler = CreateScheduler(provider); + + await scheduler.RunCycleAsync(CancellationToken.None); + + // Тенант B не архивировал (свежая карточка) — тост в его канал не публикуется. + Assert.Single(ReadToasts(subscriptionA)); + Assert.False(subscriptionB.Events.TryRead(out _)); + Assert.False(tenantContext.HasTenant); + } + + [Fact] + public async Task RunCycle_TenantTickFailure_DoesNotAbortOtherTenants() + { + // У тенанта A настройки падают (имитация сбоя схемы/БД) — тик A логирует ошибку, B обрабатывается. + FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old"); + var tenantContext = new TenantContext(); + var settingsByTenant = new Dictionary + { + [TenantA] = new ThrowingSettingsStore(), + [TenantB] = new FakeSettingsStore(), + }; + var storesByTenant = new Dictionary + { + [TenantA] = new FakeKanjStore(), + [TenantB] = storeB, + }; + await using ServiceProvider provider = BuildProvider( + new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), + tenantContext, + storesByTenant, + settingsByTenant); + + SseBroker broker = provider.GetRequiredService(); + SseSubscription subscriptionA = broker.Subscribe(TenantA); + SseSubscription subscriptionB = broker.Subscribe(TenantB); + StorageTickScheduler scheduler = CreateScheduler(provider); + + await scheduler.RunCycleAsync(CancellationToken.None); + + Assert.False(subscriptionA.Events.TryRead(out _)); + (string Text, string Icon)[] autoArchiveToast = [(Text: "Автоархив: 1 карточек", Icon: "clock")]; + Assert.Equal(autoArchiveToast, ReadToasts(subscriptionB)); + Assert.False(tenantContext.HasTenant); + } + + [Fact] + public async Task RunCycle_TenantListFailure_DoesNotThrow() + { + var tenantContext = new TenantContext(); + await using ServiceProvider provider = BuildProvider( + new ThrowingTenantRepository(), + tenantContext, + new Dictionary(), + new Dictionary()); + StorageTickScheduler scheduler = CreateScheduler(provider); + + // Сбой реестра логируется внутри цикла и не выбрасывается наружу (цикл живёт дальше). + await scheduler.RunCycleAsync(CancellationToken.None); + + Assert.False(tenantContext.HasTenant); + } + + [Fact] + public async Task RunCycle_PurgesExpiredRejectedRowsAndPublishesRejectedPurgeToast() + { + var kanjStore = new FakeKanjStore(); + var settings = new FakeSettingsStore(); + var tenantContext = new TenantContext(); + var pipelineStoreA = new FakePipelineStore(); + long nowMs = DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + pipelineStoreA.SeedRejected(Rejected("r_old_a", (long)(nowMs - TimeSpan.FromDays(4).TotalMilliseconds))); + pipelineStoreA.SeedRejected(Rejected("r_fresh", (long)(nowMs - TimeSpan.FromDays(1).TotalMilliseconds))); + await using ServiceProvider provider = BuildProvider( + new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), + tenantContext, + new Dictionary { [TenantA] = kanjStore, [TenantB] = new FakeKanjStore() }, + new Dictionary { [TenantA] = settings, [TenantB] = settings }, + new Dictionary { [TenantA] = pipelineStoreA, [TenantB] = new FakePipelineStore() }); + + SseBroker broker = provider.GetRequiredService(); + SseSubscription subscriptionA = broker.Subscribe(TenantA); + SseSubscription subscriptionB = broker.Subscribe(TenantB); + StorageTickScheduler scheduler = CreateScheduler(provider); + + await scheduler.RunCycleAsync(CancellationToken.None); + + // Автоочистка отсева в фоновом тике (Task 11, Ruling 8/9; tick_storage L485–493): запись старше + // 3 суток удалена безвозвратно, свежая пережила; тост «Отсев очищен» — только в канал тенанта A + // (у B счётчик purge = 0 — тоста нет, как notify_tick_stats L503–504). + Assert.Equal("r_fresh", Assert.Single(pipelineStoreA.Rejected).Id); + (string Text, string Icon)[] rejectedPurgeToast = [(Text: "Отсев очищен: 1 записей (3 дн.)", Icon: "trash")]; + Assert.Equal(rejectedPurgeToast, ReadToasts(subscriptionA)); + Assert.False(subscriptionB.Events.TryRead(out _)); + Assert.False(tenantContext.HasTenant); + } + + [Fact] + public async Task RunCycle_DueReminder_PublishesReminderDueToTenantChannelAndMarksFired() + { + // Тенант A: hold-карточка с напоминанием в прошлом («выстреливает») и в будущем + не-hold с прошлым + // (не «выстреливают», как check_reminders L270–275); тенант B — без due (событий в его канал нет). + var cardStoreA = new FakeKanjStore(); + cardStoreA.SeedCard(HoldCard("c_a_past", title: "Отложенный бот", reminderAtMs: NowMs() - 60_000)); + 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 FakeKanjStore(); + var tenantContext = new TenantContext(); + await using ServiceProvider provider = BuildProvider( + new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), + tenantContext, + new Dictionary { [TenantA] = cardStoreA, [TenantB] = cardStoreB }, + new Dictionary { [TenantA] = new FakeSettingsStore(), [TenantB] = new FakeSettingsStore() }, + new Dictionary { [TenantA] = new(), [TenantB] = new() }); + + SseBroker broker = provider.GetRequiredService(); + SseSubscription subscriptionA = broker.Subscribe(TenantA); + SseSubscription subscriptionB = broker.Subscribe(TenantB); + StorageTickScheduler scheduler = CreateScheduler(provider); + + await scheduler.RunCycleAsync(CancellationToken.None); + + // SSE reminder_due {id,title,containerId} по «выстрелившему» (Ruling 8; toast НЕ публикуется — счётчики + // тика нулевые), в канал только тенанта A (у B due нет). + (string Type, string Json) dueEvent = Assert.Single(ReadEvents(subscriptionA)); + Assert.Equal("reminder_due", dueEvent.Type); + using JsonDocument payload = JsonDocument.Parse(dueEvent.Json); + Assert.Equal("c_a_past", payload.RootElement.GetProperty("id").GetString()); + Assert.Equal("Отложенный бот", payload.RootElement.GetProperty("title").GetString()); + Assert.Equal("hold", payload.RootElement.GetProperty("containerId").GetString()); + Assert.False(subscriptionB.Events.TryRead(out _)); + + // «Выстрелившее» помечено fired: повторная выборка due пуста (признак держит строка БД). + Assert.Empty(await cardStoreA.ListDueRemindersAsync(DateTimeOffset.UtcNow, CancellationToken.None)); + Assert.False(tenantContext.HasTenant); + } + + [Fact] + public async Task RunCycle_RemindersDisabled_ClearsExpiredAndPublishesNoReminderDue() + { + // Выключенная настройка (Ruling 3): протухшие напоминания только очищаются, «выстрелов»/событий нет + // (check_reminders L266–269) — фон ведёт себя как ручная ветка AdminTickOrchestrator. + var cardStoreA = new FakeKanjStore(); + cardStoreA.SeedCard(HoldCard("c_past", title: "Старое", reminderAtMs: NowMs() - 60_000)); + var settingsA = new FakeSettingsStore(); + settingsA.Preload(SettingsKeys.RemindersEnabled, "false"); + var tenantContext = new TenantContext(); + await using ServiceProvider provider = BuildProvider( + new FakeTenantRepository(Tenant(TenantA)), + tenantContext, + new Dictionary { [TenantA] = cardStoreA }, + new Dictionary { [TenantA] = settingsA }, + new Dictionary { [TenantA] = new() }); + + SseBroker broker = provider.GetRequiredService(); + SseSubscription subscriptionA = broker.Subscribe(TenantA); + StorageTickScheduler scheduler = CreateScheduler(provider); + + await scheduler.RunCycleAsync(CancellationToken.None); + + Assert.False(subscriptionA.Events.TryRead(out _)); // ни reminder_due, ни тостов (счётчики нулевые) + Assert.Null(Assert.Single(cardStoreA.CardDtos).Reminder); // протухшее очищено (L268) + Assert.False(tenantContext.HasTenant); + } + + [Fact] + public async Task RunCycle_ReminderCheckFailure_DoesNotAbortTenantTickOrOtherTenants() + { + // У тенанта A проверка напоминаний падает (имитация сбоя схемы/БД на ListDueAsync) — ветка логируется + // и тик A завершается, тенант B обрабатывается (автоархив + тост) — проход жив. + var cardStoreA = new ThrowingDueKanjStore(); + FakeKanjStore storeB = StoreWithExpiredInboxCard("l_b_old"); + var tenantContext = new TenantContext(); + await using ServiceProvider provider = BuildProvider( + new FakeTenantRepository(Tenant(TenantA), Tenant(TenantB)), + tenantContext, + new Dictionary { [TenantA] = cardStoreA, [TenantB] = storeB }, + new Dictionary { [TenantA] = new FakeSettingsStore(), [TenantB] = new FakeSettingsStore() }, + new Dictionary { [TenantA] = new(), [TenantB] = new() }); + + SseBroker broker = provider.GetRequiredService(); + SseSubscription subscriptionA = broker.Subscribe(TenantA); + SseSubscription subscriptionB = broker.Subscribe(TenantB); + StorageTickScheduler scheduler = CreateScheduler(provider); + + await scheduler.RunCycleAsync(CancellationToken.None); + + Assert.False(subscriptionA.Events.TryRead(out _)); // сбой ветки — событий у A нет, тик A не «упал» + Assert.Equal(KanbanColumns.Archive, storeB.CardDtos.Single(card => card.Id == "l_b_old").Col); + (string Text, string Icon)[] autoArchiveToast = [(Text: "Автоархив: 1 карточек", Icon: "clock")]; + Assert.Equal(autoArchiveToast, ReadToasts(subscriptionB)); + Assert.False(tenantContext.HasTenant); + } + + // ─── Хелперы ──────────────────────────────────────────────────────────── + + // Строит DI-провайдер теста: системный реестр + тенант-зависимые фейки по ITenantContext. + // tenants: Фейк реестра тенантов (системный scope прохода). + // tenantContext: Реальный контекст тенанта (AsyncLocal, как в приложении). + // storeA: Хранилище канбана тенанта A. + // storeB: Хранилище канбана тенанта B. + // settings: Настройки обоих тенантов (пустые — дефолты тика). + // Возвращает: Провайдер с зарегистрированными сервисами теста. + private static ServiceProvider BuildProvider( + FakeTenantRepository tenants, + TenantContext tenantContext, + FakeKanjStore storeA, + FakeKanjStore storeB, + FakeSettingsStore settings) + { + return BuildProvider( + tenants, + tenantContext, + new Dictionary { [TenantA] = storeA, [TenantB] = storeB }, + new Dictionary { [TenantA] = settings, [TenantB] = settings }); + } + + // Строит DI-провайдер теста по явным картам тенант → хранилище/настройки. + // tenants: Фейк реестра тенантов. + // tenantContext: Реальный контекст тенанта (AsyncLocal). + // storesByTenant: Хранилища канбана по тенантам (резолвятся по текущему контексту). + // settingsByTenant: Хранилища настроек по тенантам. + // pipelineStoresByTenant: Хранилища пайплайна по тенантам (очистка отсева тика); null — пустые. + // Возвращает: Провайдер с зарегистрированными сервисами теста. + private static ServiceProvider BuildProvider( + ITenantRepository tenants, + TenantContext tenantContext, + Dictionary storesByTenant, + Dictionary settingsByTenant, + Dictionary? pipelineStoresByTenant = null) + { + pipelineStoresByTenant ??= new Dictionary + { + [TenantA] = new FakePipelineStore(), + [TenantB] = new FakePipelineStore(), + }; + + var services = new ServiceCollection(); + services.AddSingleton(tenantContext); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(tenants); + // Тенант-scoped адаптеры: реальные строят TenantDbContext по схеме текущего тенанта — фейк + // выбирает хранилище по тому же ITenantContext, который планировщик заполняет SetTenant. + services.AddScoped(provider => storesByTenant[TenantOf(provider)]); + services.AddScoped(provider => settingsByTenant[TenantOf(provider)]); + services.AddScoped(provider => pipelineStoresByTenant[TenantOf(provider)]); + // Модуль Pipeline для автоочистки отсева в фоновом тике (Task 11): PurgeExpiredAsync ходит только в + // IPipelineStore; ML-клиент не готов (как в проде этапа 4) — обработка его при purge не зовёт. + services.AddSingleton(new FakeMlClient()); + services.AddSingleton(new FakeFileStorage()); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + // Проверка напоминаний фонового тика (Task 12) — реальный CardsService на ICardStore/FakeSettingsStore + // тенанта (как в AdminTickOrchestratorTests); сбой ветки имитируется подклассом FakeKanjStore с + // падающим ListDueRemindersAsync. + services.AddScoped(); + return services.BuildServiceProvider(); + } + + // Создаёт планировщик на провайдере теста (без StartAsync — таймер 30 с не заводим). + // provider: DI-провайдер с сервисами цикла. + // Возвращает: Планировщик с NullLogger. + private static StorageTickScheduler CreateScheduler(ServiceProvider provider) + { + return new StorageTickScheduler( + provider.GetRequiredService(), + provider.GetRequiredService(), + provider.GetRequiredService(), + NullLogger.Instance); + } + + // Id текущего тенанта из контекста (резолвер фейков, как ConnectionStringProvider.ForTenant). + // provider: Scope, в котором выполняется тик тенанта. + // Возвращает: Guid тенанта из TenantId (формат N). + private static Guid TenantOf(IServiceProvider provider) + { + TenantId tenantId = provider.GetRequiredService().TenantId + ?? throw new InvalidOperationException("Тест: тик вне tenant-контекста (SetTenant не выполнен)"); + return Guid.Parse(tenantId.Value); + } + + // Запись реестра тенанта (как строка public.tenants). + // id: Идентификатор тенанта. + // Возвращает: Запись тенанта со статусом active. + private static TenantRecordDto Tenant(Guid id) => + new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow); + + // Хранилище карточек со сбоем выборки due-напоминаний: ListDueRemindersAsync бросает (сценарий + // «БД/схема недоступны» на проверке напоминаний — ветка логируется, тик тенанта/проход живы, как в + // AdminTickOrchestratorTests). + private sealed class ThrowingDueKanjStore : FakeKanjStore + { + /// + public override Task> ListDueRemindersAsync(DateTimeOffset now, CancellationToken ct) + { + throw new InvalidOperationException("Тестовый сбой выборки due-напоминаний (ListDueRemindersAsync)."); + } + } + + // Текущее время в epoch-мс (UTC) — для посева напоминаний в прошлом/будущем. + private static long NowMs() => DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + + // Карточка hold с напоминанием (как строка Cards после маппинга адаптера). + // id: Id карточки (c_...). + // title: Заголовок (ушёл в SSE reminder_due). + // reminderAtMs: Время напоминания, epoch-ms (прошлое — «выстрелит» на проходе). + // stage: Контейнер карточки (по умолчанию hold — её напоминания проверяет тик, Ruling 3). + // Возвращает: Карточка как DTO хранилища. + private static CardDto HoldCard(string id, string title, long reminderAtMs, string stage = "hold") + { + return new CardDto + { + Id = id, + Col = stage, + Title = title, + Reminder = new CardReminderDto(reminderAtMs), + CreatedAtMs = 1, + UpdatedAtMs = 1, + }; + } + + // Хранилище с просроченной карточкой «Неразобранного» — кандидатом автоархива дефолтного тика. + // cardId: Id карточки. + // Возвращает: Фейк-хранилище с одной старой карточкой inbox. + private static FakeKanjStore StoreWithExpiredInboxCard(string cardId) + { + var store = new FakeKanjStore(); + store.SeedCard(Card(cardId, KanbanColumns.Inbox, ReceivedAtMsAgo(ExpiredAge))); + return store; + } + + // Карточка в колонке с моментом получения (epoch-ms). + private static CardDto Card(string id, string col, long receivedAtMs) => + new() { Id = id, Col = col, ReceivedAtMs = receivedAtMs }; + + // Минимальная запись отсева для сидирования (purge смотрит только RejectedAtMs — 3 суток). + // id: Id записи. + // rejectedAtMs: Время отсева, epoch-ms. + // Возвращает: Запись отсева как строка БД (остальные поля пустые — не участвуют). + private static RejectedItemDto Rejected(string id, long rejectedAtMs) => new() + { + Id = id, + RejectedAtMs = rejectedAtMs, + }; + + // Epoch-ms момента «now − возраст» (ReceivedAt карточки-кандидата). + private static long ReceivedAtMsAgo(TimeSpan age) => DateTimeOffset.UtcNow.Subtract(age).ToUnixTimeMilliseconds(); + + // Читает опубликованные тосты канала: (text, icon) в порядке публикации. + // subscription: Подписка тенанта-получателя. + // Возвращает: Пары text/icon событий toast канала. + private static List<(string Text, string Icon)> ReadToasts(SseSubscription subscription) + { + var toasts = new List<(string Text, string Icon)>(); + while (subscription.Events.TryRead(out SseEvent? sseEvent)) + { + Assert.Equal("toast", sseEvent!.Type); + using JsonDocument payload = JsonDocument.Parse(sseEvent.Json); + toasts.Add(( + payload.RootElement.GetProperty("text").GetString()!, + payload.RootElement.GetProperty("icon").GetString()!)); + } + + return toasts; + } + + // Вычитывает все события канала: (тип, JSON) в порядке публикации (включая reminder_due). + // subscription: Подписка канала тенанта. + // Возвращает: События канала (toast/reminder_due) в порядке публикации. + private static List<(string Type, string Json)> ReadEvents(SseSubscription subscription) + { + var events = new List<(string Type, string Json)>(); + while (subscription.Events.TryRead(out SseEvent? sseEvent)) + { + events.Add((sseEvent!.Type, sseEvent.Json)); + } + + return events; + } + + // Реестр, у которого список тенантов падает (имитация недоступной системной БД). + private sealed class ThrowingTenantRepository : ITenantRepository + { + /// + public Task> ListAsync(CancellationToken ct) => + throw new InvalidOperationException("реестр тенантов недоступен"); + + /// + public Task FindByIdAsync(Guid id, CancellationToken ct) => + throw new NotSupportedException(); + + /// + public Task CreateAsync(TenantRecordDto tenant, CancellationToken ct) => + throw new NotSupportedException(); + + /// + public Task UpdateStatusAsync(Guid id, string status, CancellationToken ct) => + throw new NotSupportedException(); + } + + // Настройки, у которых чтение падает (имитация сбоя схемы/БД одного тенанта). + private sealed class ThrowingSettingsStore : ISettingsStore + { + /// + public Task GetAsync(string key, CancellationToken ct) => + throw new InvalidOperationException($"настройка {key} недоступна"); + + /// + public Task> GetAllAsync(CancellationToken ct) => + throw new NotSupportedException(); + + /// + public Task SetAsync(string key, string valueJson, CancellationToken ct) => + throw new NotSupportedException(); + + /// + public Task RemoveAsync(string key, CancellationToken ct) => + throw new NotSupportedException(); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/StorageTickServiceTests.cs b/src/core/tests/Deal.Tests.Unit/StorageTickServiceTests.cs index 0b0f2ef..a17fe15 100644 --- a/src/core/tests/Deal.Tests.Unit/StorageTickServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/StorageTickServiceTests.cs @@ -1,6 +1,12 @@ -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; -using Deal.Modules.Settings.Application; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/SuggestHeuristicsTests.cs b/src/core/tests/Deal.Tests.Unit/SuggestHeuristicsTests.cs index 48af887..4bf277a 100644 --- a/src/core/tests/Deal.Tests.Unit/SuggestHeuristicsTests.cs +++ b/src/core/tests/Deal.Tests.Unit/SuggestHeuristicsTests.cs @@ -1,5 +1,8 @@ -using Deal.Modules.Kanban.Application; +using Deal.Modules.Kanban.Application.Abstractions; +using Deal.Modules.Kanban.Application.Extensions; using Deal.Modules.Kanban.Application.Models; +using Deal.Modules.Kanban.Application.Registrars; +using Deal.Modules.Kanban.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/SuspiciousActivityServiceTests.cs b/src/core/tests/Deal.Tests.Unit/SuspiciousActivityServiceTests.cs index ddfb4ae..45596ed 100644 --- a/src/core/tests/Deal.Tests.Unit/SuspiciousActivityServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/SuspiciousActivityServiceTests.cs @@ -1,172 +1,175 @@ -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Tests.Unit; - -/// -/// Юнит-тесты детектора подозрительной активности (§10.5) на фейковом хранилище аудита. -/// -/// Часы фиксированы, поэтому окно анализа детерминировано; записи кладутся с At = «сейчас − минуты». -public sealed class SuspiciousActivityServiceTests -{ - private static readonly DateTimeOffset Now = new(2026, 9, 10, 12, 0, 0, TimeSpan.Zero); - private static readonly Guid Tenant = Guid.Parse("33333333-3333-3333-3333-333333333333"); - - [Fact] - public async Task AnalyzeAsync_NoRecords_ReturnsEmpty() - { - SuspiciousActivityService service = Create(new FakeAuditLogStore()); - - SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None); - - Assert.Empty(report.Items); - Assert.Equal(0, report.Scanned); - Assert.False(report.Truncated); - } - - [Fact] - public async Task AnalyzeAsync_FailedLoginsPerIp_TriggersAtThreshold() - { - var store = new FakeAuditLogStore(); - for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerIpThreshold; i++) - { - SeedFailed(store, ip: "10.0.0.1", login: "user", minutesAgo: i); - } - - SuspiciousActivityService service = Create(store); - SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None); - - SuspiciousFindingDto finding = Assert.Single( - report.Items, - item => item.Kind == SuspiciousActivityService.KindFailedLoginsPerIp); - Assert.Equal("10.0.0.1", finding.Subject); - Assert.Equal(SuspiciousActivityService.FailedLoginsPerIpThreshold, finding.Count); - Assert.Equal(SuspiciousActivityService.SeverityMedium, finding.Severity); - } - - [Fact] - public async Task AnalyzeAsync_FailedLoginsPerIp_DoubleThreshold_IsHigh() - { - var store = new FakeAuditLogStore(); - int count = SuspiciousActivityService.FailedLoginsPerIpThreshold * 2; - for (int i = 0; i < count; i++) - { - SeedFailed(store, ip: "10.0.0.1", login: $"user{i}", minutesAgo: i); - } - - SuspiciousActivityService service = Create(store); - SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None); - - SuspiciousFindingDto finding = Assert.Single( - report.Items, - item => item.Kind == SuspiciousActivityService.KindFailedLoginsPerIp); - Assert.Equal(SuspiciousActivityService.SeverityHigh, finding.Severity); - Assert.Equal(SuspiciousActivityService.KindFailedLoginsPerIp, report.Items[0].Kind); - } - - [Fact] - public async Task AnalyzeAsync_FailedLoginsPerLogin_Triggers() - { - var store = new FakeAuditLogStore(); - for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerLoginThreshold; i++) - { - SeedFailed(store, ip: $"10.0.0.{i + 1}", login: "target", minutesAgo: i); - } - - SuspiciousActivityService service = Create(store); - SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None); - - SuspiciousFindingDto finding = Assert.Single( - report.Items, - item => item.Kind == SuspiciousActivityService.KindFailedLoginsPerLogin); - Assert.Equal("target", finding.Subject); - } - - [Fact] - public async Task AnalyzeAsync_ManyIpsPerActor_Triggers() - { - var store = new FakeAuditLogStore(); - Guid actor = Guid.NewGuid(); - for (int i = 0; i < SuspiciousActivityService.DistinctIpsPerActorThreshold; i++) - { - await store.AppendAsync( - new AuditRecordDto( - AuditEvents.TenantLoginOk, - AuditActorTypes.Tenant, - ActorId: actor, - TenantId: Tenant, - Ip: $"10.1.0.{i + 1}", - DetailJson: AuditService.ToDetailJson(new { login = "user" }), - At: Now.AddMinutes(-i)), - CancellationToken.None); - } - - SuspiciousActivityService service = Create(store); - SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None); - - SuspiciousFindingDto finding = Assert.Single( - report.Items, - item => item.Kind == SuspiciousActivityService.KindManyIpsPerActor); - Assert.Equal(actor.ToString("D"), finding.Subject); - Assert.Equal(SuspiciousActivityService.DistinctIpsPerActorThreshold, finding.Count); - } - - [Fact] - public async Task AnalyzeAsync_AuthFailuresPerTenant_Triggers() - { - var store = new FakeAuditLogStore(); - for (int i = 0; i < SuspiciousActivityService.AuthFailuresPerTenantThreshold; i++) - { - await store.AppendAsync( - new AuditRecordDto( - AuditEvents.TenantLoginFailed, - AuditActorTypes.Tenant, - ActorId: null, - TenantId: Tenant, - Ip: $"10.2.0.{i + 1}", - DetailJson: AuditService.ToDetailJson(new { login = $"user{i}" }), - At: Now.AddMinutes(-i)), - CancellationToken.None); - } - - SuspiciousActivityService service = Create(store); - SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None); - - SuspiciousFindingDto finding = Assert.Single( - report.Items, - item => item.Kind == SuspiciousActivityService.KindAuthFailuresPerTenant); - Assert.Equal(Tenant.ToString("D"), finding.Subject); - } - - [Fact] - public async Task AnalyzeAsync_RecordsOutsideWindow_AreIgnored() - { - var store = new FakeAuditLogStore(); - for (int i = 0; i < 50; i++) - { - SeedFailed(store, ip: "10.0.0.1", login: "user", minutesAgo: 60 * 30); // 30 часов назад — вне суток - } - - SuspiciousActivityService service = Create(store); - SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None); - - Assert.Empty(report.Items); - } - - // Пишет запись «неудачный вход» с заданным временем. - private static void SeedFailed(FakeAuditLogStore store, string ip, string login, int minutesAgo) - { - store.AppendAsync( - new AuditRecordDto( - AuditEvents.TenantLoginFailed, - AuditActorTypes.Tenant, - ActorId: null, - TenantId: null, - Ip: ip, - DetailJson: AuditService.ToDetailJson(new { login }), - At: Now.AddMinutes(-minutesAgo)), - CancellationToken.None); - } - - private static SuspiciousActivityService Create(FakeAuditLogStore store) => new(store, () => Now); -} +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Tests.Unit; + +/// +/// Юнит-тесты детектора подозрительной активности (§10.5) на фейковом хранилище аудита. +/// +/// Часы фиксированы, поэтому окно анализа детерминировано; записи кладутся с At = «сейчас − минуты». +public sealed class SuspiciousActivityServiceTests +{ + private static readonly DateTimeOffset Now = new(2026, 9, 10, 12, 0, 0, TimeSpan.Zero); + private static readonly Guid Tenant = Guid.Parse("33333333-3333-3333-3333-333333333333"); + + [Fact] + public async Task AnalyzeAsync_NoRecords_ReturnsEmpty() + { + SuspiciousActivityService service = Create(new FakeAuditLogStore()); + + SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None); + + Assert.Empty(report.Items); + Assert.Equal(0, report.Scanned); + Assert.False(report.Truncated); + } + + [Fact] + public async Task AnalyzeAsync_FailedLoginsPerIp_TriggersAtThreshold() + { + var store = new FakeAuditLogStore(); + for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerIpThreshold; i++) + { + SeedFailed(store, ip: "10.0.0.1", login: "user", minutesAgo: i); + } + + SuspiciousActivityService service = Create(store); + SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None); + + SuspiciousFindingDto finding = Assert.Single( + report.Items, + item => item.Kind == SuspiciousActivityService.KindFailedLoginsPerIp); + Assert.Equal("10.0.0.1", finding.Subject); + Assert.Equal(SuspiciousActivityService.FailedLoginsPerIpThreshold, finding.Count); + Assert.Equal(SuspiciousActivityService.SeverityMedium, finding.Severity); + } + + [Fact] + public async Task AnalyzeAsync_FailedLoginsPerIp_DoubleThreshold_IsHigh() + { + var store = new FakeAuditLogStore(); + int count = SuspiciousActivityService.FailedLoginsPerIpThreshold * 2; + for (int i = 0; i < count; i++) + { + SeedFailed(store, ip: "10.0.0.1", login: $"user{i}", minutesAgo: i); + } + + SuspiciousActivityService service = Create(store); + SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None); + + SuspiciousFindingDto finding = Assert.Single( + report.Items, + item => item.Kind == SuspiciousActivityService.KindFailedLoginsPerIp); + Assert.Equal(SuspiciousActivityService.SeverityHigh, finding.Severity); + Assert.Equal(SuspiciousActivityService.KindFailedLoginsPerIp, report.Items[0].Kind); + } + + [Fact] + public async Task AnalyzeAsync_FailedLoginsPerLogin_Triggers() + { + var store = new FakeAuditLogStore(); + for (int i = 0; i < SuspiciousActivityService.FailedLoginsPerLoginThreshold; i++) + { + SeedFailed(store, ip: $"10.0.0.{i + 1}", login: "target", minutesAgo: i); + } + + SuspiciousActivityService service = Create(store); + SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None); + + SuspiciousFindingDto finding = Assert.Single( + report.Items, + item => item.Kind == SuspiciousActivityService.KindFailedLoginsPerLogin); + Assert.Equal("target", finding.Subject); + } + + [Fact] + public async Task AnalyzeAsync_ManyIpsPerActor_Triggers() + { + var store = new FakeAuditLogStore(); + Guid actor = Guid.NewGuid(); + for (int i = 0; i < SuspiciousActivityService.DistinctIpsPerActorThreshold; i++) + { + await store.AppendAsync( + new AuditRecordDto( + AuditEvents.TenantLoginOk, + AuditActorTypes.Tenant, + ActorId: actor, + TenantId: Tenant, + Ip: $"10.1.0.{i + 1}", + DetailJson: AuditService.ToDetailJson(new { login = "user" }), + At: Now.AddMinutes(-i)), + CancellationToken.None); + } + + SuspiciousActivityService service = Create(store); + SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None); + + SuspiciousFindingDto finding = Assert.Single( + report.Items, + item => item.Kind == SuspiciousActivityService.KindManyIpsPerActor); + Assert.Equal(actor.ToString("D"), finding.Subject); + Assert.Equal(SuspiciousActivityService.DistinctIpsPerActorThreshold, finding.Count); + } + + [Fact] + public async Task AnalyzeAsync_AuthFailuresPerTenant_Triggers() + { + var store = new FakeAuditLogStore(); + for (int i = 0; i < SuspiciousActivityService.AuthFailuresPerTenantThreshold; i++) + { + await store.AppendAsync( + new AuditRecordDto( + AuditEvents.TenantLoginFailed, + AuditActorTypes.Tenant, + ActorId: null, + TenantId: Tenant, + Ip: $"10.2.0.{i + 1}", + DetailJson: AuditService.ToDetailJson(new { login = $"user{i}" }), + At: Now.AddMinutes(-i)), + CancellationToken.None); + } + + SuspiciousActivityService service = Create(store); + SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None); + + SuspiciousFindingDto finding = Assert.Single( + report.Items, + item => item.Kind == SuspiciousActivityService.KindAuthFailuresPerTenant); + Assert.Equal(Tenant.ToString("D"), finding.Subject); + } + + [Fact] + public async Task AnalyzeAsync_RecordsOutsideWindow_AreIgnored() + { + var store = new FakeAuditLogStore(); + for (int i = 0; i < 50; i++) + { + SeedFailed(store, ip: "10.0.0.1", login: "user", minutesAgo: 60 * 30); // 30 часов назад — вне суток + } + + SuspiciousActivityService service = Create(store); + SuspiciousActivityDto report = await service.AnalyzeAsync(null, null, CancellationToken.None); + + Assert.Empty(report.Items); + } + + // Пишет запись «неудачный вход» с заданным временем. + private static void SeedFailed(FakeAuditLogStore store, string ip, string login, int minutesAgo) + { + store.AppendAsync( + new AuditRecordDto( + AuditEvents.TenantLoginFailed, + AuditActorTypes.Tenant, + ActorId: null, + TenantId: null, + Ip: ip, + DetailJson: AuditService.ToDetailJson(new { login }), + At: Now.AddMinutes(-minutesAgo)), + CancellationToken.None); + } + + private static SuspiciousActivityService Create(FakeAuditLogStore store) => new(store, () => Now); +} diff --git a/src/core/tests/Deal.Tests.Unit/TelegramIngressServiceTests.cs b/src/core/tests/Deal.Tests.Unit/TelegramIngressServiceTests.cs index 616f73d..747effa 100644 --- a/src/core/tests/Deal.Tests.Unit/TelegramIngressServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/TelegramIngressServiceTests.cs @@ -1,479 +1,487 @@ -using System.Text.Json; -using Deal.Api.Events; -using Deal.Grpc.Telegram; -using Deal.Modules.Pipeline.Application; -using Deal.Modules.Pipeline.Application.Models; -using Deal.Modules.Settings.Application; -using Deal.Modules.Telegram.Application; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; -using Grpc.Core; -using Grpc.Net.Client; -using Microsoft.Extensions.DependencyInjection; - -namespace Deal.Tests.Unit; - -/// -/// In-proc gRPC-тесты входящего потока telegram-service → ядро (план Task 12, L361–377; Ruling 1/7). -/// -/// Хост Deal.Api-ингресса (Kestrel HTTP/2, эфемерный порт) поднимается в процессе теста через -/// — те же регистрации, что в Program.cs (AddGrpc + -/// IngressServiceTokenInterceptor + TelegramIngressService), но tenant-адаптеры заменены фейками -/// (реестр FakeTenantRegistry, FakePipelineStore/FakeSettingsStore — сквозная проверка без Telegram/БД). -/// Сценарии: PushMessage кладёт строку очереди тенанта (приём/дубль dialog+msgId/неизвестный тенант → -/// not-accepted без падения RPC) + превью (модуль Telegram, Task 13: строка TgMessages и «последнее -/// сообщение» каталога); интерцептор service-token и metadata tenant-id (UNAUTHENTICATED); -/// SyncDialogs применяет каталог и отвечает актуальным списком monitored id (авто-мониторинг новых — -/// по настройке autoMonitorNew); ReportStatus пишет KV tgStatus/tgAccount и публикует SSE -/// system_status/тосты на переходах connected. -/// -public sealed class TelegramIngressServiceTests -{ - // Токен сценариев теста. - private const string ValidToken = TelegramIngressTestHost.DefaultToken; - - // Тенант A сценариев (в реестре). - private static readonly Guid TenantA = Guid.NewGuid(); - - // Id диалога сценариев PushMessage. - private const string DialogId = "d_channel_100"; - - // ─── PushMessage: приём, дубль, несуществующий тенант ────────────────── - - /// - /// PushMessage кладёт строку очереди тенанта (эмуляция входящего сообщения — сквозная проверка без - /// Telegram, план Task 12): accepted=true, строка в очереди фейка с канальными полями и msgId. - /// - [Fact] - public async Task PushMessage_ValidTenant_EnqueuesQueueRowAndAccepts() - { - var store = new FakePipelineStore(); - var registry = new FakeTenantRegistry(Tenant(TenantA)); - - await RunAsync( - registry, - services => services.AddScoped(_ => store), - async channel => - { - PushMessageReply reply = await PushAsync(channel, Request("Вакансия Python-разработчика", msgId: 7), TenantA, ValidToken); - - Assert.True(reply.Accepted); - Assert.False(reply.Duplicate); - - QueueItemDto row = Assert.Single(store.Queue); - Assert.Equal(DialogId, row.DialogId); - Assert.Equal(7, row.MsgId); - Assert.Equal("Вакансия Python-разработчика", row.Text); - Assert.Equal("Канал", row.Channel.Name); - Assert.Equal("kanal_handle", row.Channel.Handle); - Assert.Equal("#a33", row.Channel.Hue); - }); - } - - /// - /// Повтор PushMessage того же dialogId+msgId — duplicate=true, очередь не растёт (гвард EnqueueAsync). - /// - [Fact] - public async Task PushMessage_SameDialogAndMsgIdTwice_SecondIsDuplicateAndQueueNotGrown() - { - var store = new FakePipelineStore(); - var registry = new FakeTenantRegistry(Tenant(TenantA)); - - await RunAsync( - registry, - services => services.AddScoped(_ => store), - async channel => - { - PushMessageReply first = await PushAsync(channel, Request("Сообщение канала", msgId: 42), TenantA, ValidToken); - PushMessageReply second = await PushAsync(channel, Request("Сообщение канала", msgId: 42), TenantA, ValidToken); - - Assert.True(first.Accepted); - Assert.True(second.Accepted); - Assert.True(second.Duplicate); - Assert.Single(store.Queue); - }); - } - - /// - /// PushMessage для несуществующего тенанта не падает (нет записи в реестре/схемы → ошибка ловится, - /// reply not-accepted, план Task 12): RPC завершается штатно, очередь не растёт, исключения нет. - /// - [Fact] - public async Task PushMessage_UnknownTenant_NotAcceptedWithoutRpcError() - { - var store = new FakePipelineStore(); - var registry = new FakeTenantRegistry(Tenant(TenantA)); - - await RunAsync( - registry, - services => services.AddScoped(_ => store), - async channel => - { - PushMessageReply reply = await PushAsync(channel, Request("Сообщение чужого тенанта"), Guid.NewGuid(), ValidToken); - - Assert.False(reply.Accepted); - Assert.False(reply.Duplicate); - Assert.Empty(store.Queue); - }); - } - - // ─── Интерцептор service-token и metadata tenant-id ───────────────────── - - /// - /// Запрос без metadata «service-token» → UNAUTHENTICATED (Ruling 1). - /// - [Fact] - public async Task PushMessage_WithoutToken_IsUnauthenticated() - { - var store = new FakePipelineStore(); - var registry = new FakeTenantRegistry(Tenant(TenantA)); - - await RunAsync(registry, services => services.AddScoped(_ => store), async channel => - { - RpcException exception = await Assert.ThrowsAsync( - () => PushAsync(channel, Request("текст"), TenantA, null)); - Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); - }); - } - - /// - /// Запрос с неверным токеном → UNAUTHENTICATED (Ruling 1). - /// - [Fact] - public async Task PushMessage_WithWrongToken_IsUnauthenticated() - { - var store = new FakePipelineStore(); - var registry = new FakeTenantRegistry(Tenant(TenantA)); - - await RunAsync(registry, services => services.AddScoped(_ => store), async channel => - { - RpcException exception = await Assert.ThrowsAsync( - () => PushAsync(channel, Request("текст"), TenantA, "wrong-token")); - Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); - }); - } - - /// - /// Fail-closed: DEAL_SERVICE_TOKEN не задан — RPC ингресса отклоняется даже с «каким-то» токеном. - /// - [Fact] - public async Task PushMessage_UnsetEnvToken_FailsClosed() - { - var store = new FakePipelineStore(); - var registry = new FakeTenantRegistry(Tenant(TenantA)); - - await TelegramIngressTestHost.RunAsync( - serviceToken: null, - configureServices: services => - { - services.AddSingleton(registry); - services.AddScoped(_ => new FakeSettingsStore()); - services.AddScoped(_ => store); - }, - scenario: async channel => - { - RpcException exception = await Assert.ThrowsAsync( - () => PushAsync(channel, Request("текст"), TenantA, ValidToken)); - Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); - }); - } - - /// - /// Metadata tenant-id отсутствует → UNAUTHENTICATED (README src/contracts: tenant-id обязателен). - /// - [Fact] - public async Task PushMessage_WithoutTenantIdMetadata_IsUnauthenticated() - { - var store = new FakePipelineStore(); - var registry = new FakeTenantRegistry(Tenant(TenantA)); - - await RunAsync(registry, services => services.AddScoped(_ => store), async channel => - { - RpcException exception = await Assert.ThrowsAsync( - () => PushAsync(channel, Request("текст"), null, ValidToken)); - Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); - }); - } - - // ─── SyncDialogs и ReportStatus ───────────────────────────────────────── - - /// - /// SyncDialogs применяет каталог тенанта к таблице Dialogs и отвечает актуальным списком monitored id - /// (план Task 13, Ruling 7): по умолчанию autoMonitorNew=true — новые диалоги каталога появляются - /// включёнными, ответ содержит их id (зеркало telegram-service обновится по reply). - /// - [Fact] - public async Task SyncDialogs_KnownTenant_AppliesCatalogAndReturnsMonitoredIds() - { - var registry = new FakeTenantRegistry(Tenant(TenantA)); - var dialogs = new FakeTelegramStore(); - - await RunAsync( - registry, - services => services.AddScoped(_ => dialogs), - async channel => - { - var request = new SyncDialogsRequest(); - request.Entries.Add(new DialogEntry { Id = "d_1", Name = "Канал", Kind = "channel", Hue = "#a33" }); - request.Entries.Add(new DialogEntry { Id = "d_2", Name = "Группа", Kind = "group", Hue = "#b44" }); - - SyncDialogsReply reply = await SyncAsync(channel, request, TenantA); - - // Новые диалоги авто-мониторятся (дефолт autoMonitorNew=true) — reply несёт их id. - Assert.Equal(["d_1", "d_2"], reply.MonitoredIds); - Assert.True(dialogs.Dialogs.All(row => row.Monitor)); - }); - } - - /// - /// SyncDialogs при autoMonitorNew=false добавляет новые диалоги отключёнными — зеркало мониторинга пусто - /// (Ruling 7): ответ monitored_ids пуст, каталог применён (строки Dialogs есть). - /// - [Fact] - public async Task SyncDialogs_AutoMonitorNewDisabled_ReturnsEmptyMonitoredMirror() - { - var registry = new FakeTenantRegistry(Tenant(TenantA)); - var settings = new FakeSettingsStore(); - settings.Preload(SettingsKeys.AutoMonitorNew, "false"); - var dialogs = new FakeTelegramStore(); - - await RunAsync( - registry, - services => - { - services.AddScoped(_ => settings); - services.AddScoped(_ => dialogs); - }, - async channel => - { - var request = new SyncDialogsRequest(); - request.Entries.Add(new DialogEntry { Id = "d_1", Name = "Канал", Kind = "channel" }); - - SyncDialogsReply reply = await SyncAsync(channel, request, TenantA); - - Assert.Empty(reply.MonitoredIds); - Assert.Single(dialogs.Dialogs); - Assert.False(dialogs.Dialogs[0].Monitor); - }); - } - - /// - /// ReportStatus: KV tgStatus/tgAccount на каждый репорт, SSE system_status на каждый репорт и тосты - /// только на переходах connected (false→true «подключён», true→false «отключён») — как сервис шлёт - /// статус heartbeat'ом, без гарда переходов тосты дублировались бы (Ruling 7). - /// - [Fact] - public async Task ReportStatus_PersistsStatusAndPublishesTransitionToasts() - { - var settings = new FakeSettingsStore(); - var registry = new FakeTenantRegistry(Tenant(TenantA)); - var broker = new SseBroker(); - - await RunAsync( - registry, - services => - { - services.AddSingleton(broker); - services.AddScoped(_ => settings); - }, - async channel => - { - SseSubscription subscription = broker.Subscribe(TenantA); - var client = new IngressService.IngressServiceClient(channel); - - // Первый репорт (подключение ещё не завершено): KV пишется, публикуется только system_status. - ReportStatusReply first = await ReportAsync(client, connected: false, phase: "idle", account: "@user1", tenantId: TenantA); - Assert.True(first.Ok); - AssertStatusStored(settings, phase: "idle", connected: false); - Assert.Equal("\"@user1\"", settings.GetStoredJson(SettingsKeys.TgAccount)); - Assert.Single(ReadEvents(subscription, SystemStatusEventType)); - Assert.Empty(ReadEvents(subscription, ToastEventType)); - - // Переход false→true (вход по QR завершён): тост «Telegram подключён, сессия сохранена». - ReportStatusReply connected = await ReportAsync(client, connected: true, phase: "ready", account: "@user1", tenantId: TenantA); - Assert.True(connected.Ok); - AssertStatusStored(settings, phase: "ready", connected: true); - Assert.Equal(ConnectedToastText, Assert.Single(ReadEvents(subscription, ToastEventType))); - - // Повторный connected (heartbeat ready-фазы) — тоста нет (гард переходов фаз). - await ReportAsync(client, connected: true, phase: "ready", account: "@user1", tenantId: TenantA); - Assert.Empty(ReadEvents(subscription, ToastEventType)); - - // Переход true→false (выход из аккаунта): тост «Telegram отключён». - await ReportAsync(client, connected: false, phase: "idle", account: string.Empty, tenantId: TenantA); - AssertStatusStored(settings, phase: "idle", connected: false); - Assert.Equal(DisconnectedToastText, Assert.Single(ReadEvents(subscription, ToastEventType))); - }); - } - - /// - /// ReportStatus для несуществующего тенанта не падает — ok=false, KV не тронут (план Task 12). - /// - [Fact] - public async Task ReportStatus_UnknownTenant_NotSavedWithoutRpcError() - { - var settings = new FakeSettingsStore(); - var registry = new FakeTenantRegistry(Tenant(TenantA)); - - await RunAsync( - registry, - services => services.AddScoped(_ => settings), - async channel => - { - var client = new IngressService.IngressServiceClient(channel); - ReportStatusReply reply = await ReportAsync(client, connected: true, phase: "ready", account: "@user1", tenantId: Guid.NewGuid()); - - Assert.False(reply.Ok); - Assert.Null(settings.GetStoredJson(SettingsKeys.TgStatus)); - Assert.Null(settings.GetStoredJson(SettingsKeys.TgAccount)); - }); - } - - // ─── Контекст и хелперы ───────────────────────────────────────────────── - - // Тип SSE-события статуса Telegram (зеркало TelegramIngressService). - private const string SystemStatusEventType = "system_status"; - - // Тип SSE-события тоста (зеркало TelegramIngressService). - private const string ToastEventType = "toast"; - - // Текст тоста подключения (зеркало TelegramIngressService, Ruling 7). - private const string ConnectedToastText = "Telegram подключён, сессия сохранена"; - - // Текст тоста отключения (зеркало TelegramIngressService, Ruling 7). - private const string DisconnectedToastText = "Telegram отключён"; - - // Запись реестра тенанта (как строка public.tenants). - // id: Идентификатор тенанта. - private static TenantRecordDto Tenant(Guid id) => - new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow); - - // Поднимает хост ингресса: реестр тенантов + tenant-адаптеры сценария (общий харнесс). - // registry: Реестр тенантов сценария. - // registerTenantServices: Дополнительные tenant-scoped адаптеры сценария - // (IPipelineStore/ISettingsStore/брокер). - // scenario: Сценарий с gRPC-каналом. - private static Task RunAsync( - FakeTenantRegistry registry, - Action registerTenantServices, - Func scenario) - => TelegramIngressTestHost.RunAsync( - ValidToken, - services => - { - services.AddSingleton(registry); - registerTenantServices(services); - }, - scenario); - - // Вызывает PushMessage с metadata сценария (deadline 10 с — контракт README). - // channel: Канал к хосту. - // request: Запрос PushMessage. - // tenantId: Id тенанта в metadata (null — без заголовка tenant-id). - // tokenHeader: Значение metadata «service-token» (null — без заголовка). - private static Task PushAsync(GrpcChannel channel, PushMessageRequest request, Guid? tenantId, string? tokenHeader) - { - var client = new IngressService.IngressServiceClient(channel); - AsyncUnaryCall call = client.PushMessageAsync( - request, - new CallOptions(TelegramIngressTestHost.CallMetadata(tokenHeader, tenantId), deadline: Deadline())); - return call.ResponseAsync; - } - - // Вызывает SyncDialogs с metadata сценария. - // channel: Канал к хосту. - // request: Запрос SyncDialogs. - // tenantId: Id тенанта в metadata (null — без заголовка tenant-id). - private static Task SyncAsync(GrpcChannel channel, SyncDialogsRequest request, Guid? tenantId) - { - var client = new IngressService.IngressServiceClient(channel); - AsyncUnaryCall call = client.SyncDialogsAsync( - request, - new CallOptions(TelegramIngressTestHost.CallMetadata(ValidToken, tenantId), deadline: Deadline())); - return call.ResponseAsync; - } - - // Вызывает ReportStatus с metadata сценария. - // client: Клиент ингресса. - // connected: Флаг connected репорта. - // phase: Фаза репорта. - // account: Аккаунт репорта. - // tenantId: Id тенанта в metadata (null — без заголовка tenant-id). - private static Task ReportAsync( - IngressService.IngressServiceClient client, - bool connected, - string phase, - string account, - Guid? tenantId) - { - var request = new ReportStatusRequest - { - Phase = phase, - Connected = connected, - Listener = true, - Account = account, - }; - AsyncUnaryCall call = client.ReportStatusAsync( - request, - new CallOptions(TelegramIngressTestHost.CallMetadata(ValidToken, tenantId), deadline: Deadline())); - return call.ResponseAsync; - } - - // Запрос PushMessage сценария (канальные поля фиксированы). - // text: Текст сообщения. - // msgId: Id сообщения в Telegram (null — без msg_id). - private static PushMessageRequest Request(string text, long? msgId = null) - { - var request = new PushMessageRequest - { - DialogId = DialogId, - ChannelName = "Канал", - ChannelHandle = "kanal_handle", - ChannelHue = "#a33", - Text = text, - }; - if (msgId is not null) - { - request.MsgId = msgId.Value; - } - - return request; - } - - // Deadline вызовов теста (контракт ингресса — 10 с). - private static DateTime Deadline() - => DateTime.UtcNow.AddSeconds(TelegramIngressTestHost.RpcDeadlineSeconds); - - // Читает события канала: текст тоста (поле text) либо JSON события для прочих типов. - // subscription: Подписка канала тенанта. - // eventType: Тип события. - private static List ReadEvents(SseSubscription subscription, string eventType) - { - var texts = new List(); - while (subscription.Events.TryRead(out SseEvent? sseEvent)) - { - if (sseEvent!.Type != eventType) - { - continue; - } - - using JsonDocument payload = JsonDocument.Parse(sseEvent.Json); - texts.Add(payload.RootElement.TryGetProperty("text", out JsonElement text) ? text.GetString()! : sseEvent.Json); - } - - return texts; - } - - // Проверяет сохранённый KV tgStatus (camelCase JSON): фаза и флаг connected. - // settings: KV-хранилище сценария. - // phase: Ожидаемая фаза. - // connected: Ожидаемый флаг connected. - private static void AssertStatusStored(FakeSettingsStore settings, string phase, bool connected) - { - string? json = settings.GetStoredJson(SettingsKeys.TgStatus); - Assert.NotNull(json); - using JsonDocument stored = JsonDocument.Parse(json!); - Assert.Equal(phase, stored.RootElement.GetProperty("phase").GetString()); - Assert.Equal(connected, stored.RootElement.GetProperty("connected").GetBoolean()); - } -} +using System.Text.Json; +using Deal.Api.Events; +using Deal.Grpc.Telegram; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Telegram.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; +using Grpc.Core; +using Grpc.Net.Client; +using Microsoft.Extensions.DependencyInjection; + +namespace Deal.Tests.Unit; + +/// +/// In-proc gRPC-тесты входящего потока telegram-service → ядро (план Task 12, L361–377; Ruling 1/7). +/// +/// Хост Deal.Api-ингресса (Kestrel HTTP/2, эфемерный порт) поднимается в процессе теста через +/// — те же регистрации, что в Program.cs (AddGrpc + +/// IngressServiceTokenInterceptor + TelegramIngressService), но tenant-адаптеры заменены фейками +/// (реестр FakeTenantRegistry, FakePipelineStore/FakeSettingsStore — сквозная проверка без Telegram/БД). +/// Сценарии: PushMessage кладёт строку очереди тенанта (приём/дубль dialog+msgId/неизвестный тенант → +/// not-accepted без падения RPC) + превью (модуль Telegram, Task 13: строка TgMessages и «последнее +/// сообщение» каталога); интерцептор service-token и metadata tenant-id (UNAUTHENTICATED); +/// SyncDialogs применяет каталог и отвечает актуальным списком monitored id (авто-мониторинг новых — +/// по настройке autoMonitorNew); ReportStatus пишет KV tgStatus/tgAccount и публикует SSE +/// system_status/тосты на переходах connected. +/// +public sealed class TelegramIngressServiceTests +{ + // Токен сценариев теста. + private const string ValidToken = TelegramIngressTestHost.DefaultToken; + + // Тенант A сценариев (в реестре). + private static readonly Guid TenantA = Guid.NewGuid(); + + // Id диалога сценариев PushMessage. + private const string DialogId = "d_channel_100"; + + // ─── PushMessage: приём, дубль, несуществующий тенант ────────────────── + + /// + /// PushMessage кладёт строку очереди тенанта (эмуляция входящего сообщения — сквозная проверка без + /// Telegram, план Task 12): accepted=true, строка в очереди фейка с канальными полями и msgId. + /// + [Fact] + public async Task PushMessage_ValidTenant_EnqueuesQueueRowAndAccepts() + { + var store = new FakePipelineStore(); + var registry = new FakeTenantRegistry(Tenant(TenantA)); + + await RunAsync( + registry, + services => services.AddScoped(_ => store), + async channel => + { + PushMessageReply reply = await PushAsync(channel, Request("Вакансия Python-разработчика", msgId: 7), TenantA, ValidToken); + + Assert.True(reply.Accepted); + Assert.False(reply.Duplicate); + + QueueItemDto row = Assert.Single(store.Queue); + Assert.Equal(DialogId, row.DialogId); + Assert.Equal(7, row.MsgId); + Assert.Equal("Вакансия Python-разработчика", row.Text); + Assert.Equal("Канал", row.Channel.Name); + Assert.Equal("kanal_handle", row.Channel.Handle); + Assert.Equal("#a33", row.Channel.Hue); + }); + } + + /// + /// Повтор PushMessage того же dialogId+msgId — duplicate=true, очередь не растёт (гвард EnqueueAsync). + /// + [Fact] + public async Task PushMessage_SameDialogAndMsgIdTwice_SecondIsDuplicateAndQueueNotGrown() + { + var store = new FakePipelineStore(); + var registry = new FakeTenantRegistry(Tenant(TenantA)); + + await RunAsync( + registry, + services => services.AddScoped(_ => store), + async channel => + { + PushMessageReply first = await PushAsync(channel, Request("Сообщение канала", msgId: 42), TenantA, ValidToken); + PushMessageReply second = await PushAsync(channel, Request("Сообщение канала", msgId: 42), TenantA, ValidToken); + + Assert.True(first.Accepted); + Assert.True(second.Accepted); + Assert.True(second.Duplicate); + Assert.Single(store.Queue); + }); + } + + /// + /// PushMessage для несуществующего тенанта не падает (нет записи в реестре/схемы → ошибка ловится, + /// reply not-accepted, план Task 12): RPC завершается штатно, очередь не растёт, исключения нет. + /// + [Fact] + public async Task PushMessage_UnknownTenant_NotAcceptedWithoutRpcError() + { + var store = new FakePipelineStore(); + var registry = new FakeTenantRegistry(Tenant(TenantA)); + + await RunAsync( + registry, + services => services.AddScoped(_ => store), + async channel => + { + PushMessageReply reply = await PushAsync(channel, Request("Сообщение чужого тенанта"), Guid.NewGuid(), ValidToken); + + Assert.False(reply.Accepted); + Assert.False(reply.Duplicate); + Assert.Empty(store.Queue); + }); + } + + // ─── Интерцептор service-token и metadata tenant-id ───────────────────── + + /// + /// Запрос без metadata «service-token» → UNAUTHENTICATED (Ruling 1). + /// + [Fact] + public async Task PushMessage_WithoutToken_IsUnauthenticated() + { + var store = new FakePipelineStore(); + var registry = new FakeTenantRegistry(Tenant(TenantA)); + + await RunAsync(registry, services => services.AddScoped(_ => store), async channel => + { + RpcException exception = await Assert.ThrowsAsync( + () => PushAsync(channel, Request("текст"), TenantA, null)); + Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); + }); + } + + /// + /// Запрос с неверным токеном → UNAUTHENTICATED (Ruling 1). + /// + [Fact] + public async Task PushMessage_WithWrongToken_IsUnauthenticated() + { + var store = new FakePipelineStore(); + var registry = new FakeTenantRegistry(Tenant(TenantA)); + + await RunAsync(registry, services => services.AddScoped(_ => store), async channel => + { + RpcException exception = await Assert.ThrowsAsync( + () => PushAsync(channel, Request("текст"), TenantA, "wrong-token")); + Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); + }); + } + + /// + /// Fail-closed: DEAL_SERVICE_TOKEN не задан — RPC ингресса отклоняется даже с «каким-то» токеном. + /// + [Fact] + public async Task PushMessage_UnsetEnvToken_FailsClosed() + { + var store = new FakePipelineStore(); + var registry = new FakeTenantRegistry(Tenant(TenantA)); + + await TelegramIngressTestHost.RunAsync( + serviceToken: null, + configureServices: services => + { + services.AddSingleton(registry); + services.AddScoped(_ => new FakeSettingsStore()); + services.AddScoped(_ => store); + }, + scenario: async channel => + { + RpcException exception = await Assert.ThrowsAsync( + () => PushAsync(channel, Request("текст"), TenantA, ValidToken)); + Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); + }); + } + + /// + /// Metadata tenant-id отсутствует → UNAUTHENTICATED (README src/contracts: tenant-id обязателен). + /// + [Fact] + public async Task PushMessage_WithoutTenantIdMetadata_IsUnauthenticated() + { + var store = new FakePipelineStore(); + var registry = new FakeTenantRegistry(Tenant(TenantA)); + + await RunAsync(registry, services => services.AddScoped(_ => store), async channel => + { + RpcException exception = await Assert.ThrowsAsync( + () => PushAsync(channel, Request("текст"), null, ValidToken)); + Assert.Equal(StatusCode.Unauthenticated, exception.StatusCode); + }); + } + + // ─── SyncDialogs и ReportStatus ───────────────────────────────────────── + + /// + /// SyncDialogs применяет каталог тенанта к таблице Dialogs и отвечает актуальным списком monitored id + /// (план Task 13, Ruling 7): по умолчанию autoMonitorNew=true — новые диалоги каталога появляются + /// включёнными, ответ содержит их id (зеркало telegram-service обновится по reply). + /// + [Fact] + public async Task SyncDialogs_KnownTenant_AppliesCatalogAndReturnsMonitoredIds() + { + var registry = new FakeTenantRegistry(Tenant(TenantA)); + var dialogs = new FakeTelegramStore(); + + await RunAsync( + registry, + services => services.AddScoped(_ => dialogs), + async channel => + { + var request = new SyncDialogsRequest(); + request.Entries.Add(new DialogEntry { Id = "d_1", Name = "Канал", Kind = "channel", Hue = "#a33" }); + request.Entries.Add(new DialogEntry { Id = "d_2", Name = "Группа", Kind = "group", Hue = "#b44" }); + + SyncDialogsReply reply = await SyncAsync(channel, request, TenantA); + + // Новые диалоги авто-мониторятся (дефолт autoMonitorNew=true) — reply несёт их id. + Assert.Equal(["d_1", "d_2"], reply.MonitoredIds); + Assert.True(dialogs.Dialogs.All(row => row.Monitor)); + }); + } + + /// + /// SyncDialogs при autoMonitorNew=false добавляет новые диалоги отключёнными — зеркало мониторинга пусто + /// (Ruling 7): ответ monitored_ids пуст, каталог применён (строки Dialogs есть). + /// + [Fact] + public async Task SyncDialogs_AutoMonitorNewDisabled_ReturnsEmptyMonitoredMirror() + { + var registry = new FakeTenantRegistry(Tenant(TenantA)); + var settings = new FakeSettingsStore(); + settings.Preload(SettingsKeys.AutoMonitorNew, "false"); + var dialogs = new FakeTelegramStore(); + + await RunAsync( + registry, + services => + { + services.AddScoped(_ => settings); + services.AddScoped(_ => dialogs); + }, + async channel => + { + var request = new SyncDialogsRequest(); + request.Entries.Add(new DialogEntry { Id = "d_1", Name = "Канал", Kind = "channel" }); + + SyncDialogsReply reply = await SyncAsync(channel, request, TenantA); + + Assert.Empty(reply.MonitoredIds); + Assert.Single(dialogs.Dialogs); + Assert.False(dialogs.Dialogs[0].Monitor); + }); + } + + /// + /// ReportStatus: KV tgStatus/tgAccount на каждый репорт, SSE system_status на каждый репорт и тосты + /// только на переходах connected (false→true «подключён», true→false «отключён») — как сервис шлёт + /// статус heartbeat'ом, без гарда переходов тосты дублировались бы (Ruling 7). + /// + [Fact] + public async Task ReportStatus_PersistsStatusAndPublishesTransitionToasts() + { + var settings = new FakeSettingsStore(); + var registry = new FakeTenantRegistry(Tenant(TenantA)); + var broker = new SseBroker(); + + await RunAsync( + registry, + services => + { + services.AddSingleton(broker); + services.AddScoped(_ => settings); + }, + async channel => + { + SseSubscription subscription = broker.Subscribe(TenantA); + var client = new IngressService.IngressServiceClient(channel); + + // Первый репорт (подключение ещё не завершено): KV пишется, публикуется только system_status. + ReportStatusReply first = await ReportAsync(client, connected: false, phase: "idle", account: "@user1", tenantId: TenantA); + Assert.True(first.Ok); + AssertStatusStored(settings, phase: "idle", connected: false); + Assert.Equal("\"@user1\"", settings.GetStoredJson(SettingsKeys.TgAccount)); + Assert.Single(ReadEvents(subscription, SystemStatusEventType)); + Assert.Empty(ReadEvents(subscription, ToastEventType)); + + // Переход false→true (вход по QR завершён): тост «Telegram подключён, сессия сохранена». + ReportStatusReply connected = await ReportAsync(client, connected: true, phase: "ready", account: "@user1", tenantId: TenantA); + Assert.True(connected.Ok); + AssertStatusStored(settings, phase: "ready", connected: true); + Assert.Equal(ConnectedToastText, Assert.Single(ReadEvents(subscription, ToastEventType))); + + // Повторный connected (heartbeat ready-фазы) — тоста нет (гард переходов фаз). + await ReportAsync(client, connected: true, phase: "ready", account: "@user1", tenantId: TenantA); + Assert.Empty(ReadEvents(subscription, ToastEventType)); + + // Переход true→false (выход из аккаунта): тост «Telegram отключён». + await ReportAsync(client, connected: false, phase: "idle", account: string.Empty, tenantId: TenantA); + AssertStatusStored(settings, phase: "idle", connected: false); + Assert.Equal(DisconnectedToastText, Assert.Single(ReadEvents(subscription, ToastEventType))); + }); + } + + /// + /// ReportStatus для несуществующего тенанта не падает — ok=false, KV не тронут (план Task 12). + /// + [Fact] + public async Task ReportStatus_UnknownTenant_NotSavedWithoutRpcError() + { + var settings = new FakeSettingsStore(); + var registry = new FakeTenantRegistry(Tenant(TenantA)); + + await RunAsync( + registry, + services => services.AddScoped(_ => settings), + async channel => + { + var client = new IngressService.IngressServiceClient(channel); + ReportStatusReply reply = await ReportAsync(client, connected: true, phase: "ready", account: "@user1", tenantId: Guid.NewGuid()); + + Assert.False(reply.Ok); + Assert.Null(settings.GetStoredJson(SettingsKeys.TgStatus)); + Assert.Null(settings.GetStoredJson(SettingsKeys.TgAccount)); + }); + } + + // ─── Контекст и хелперы ───────────────────────────────────────────────── + + // Тип SSE-события статуса Telegram (зеркало TelegramIngressService). + private const string SystemStatusEventType = "system_status"; + + // Тип SSE-события тоста (зеркало TelegramIngressService). + private const string ToastEventType = "toast"; + + // Текст тоста подключения (зеркало TelegramIngressService, Ruling 7). + private const string ConnectedToastText = "Telegram подключён, сессия сохранена"; + + // Текст тоста отключения (зеркало TelegramIngressService, Ruling 7). + private const string DisconnectedToastText = "Telegram отключён"; + + // Запись реестра тенанта (как строка public.tenants). + // id: Идентификатор тенанта. + private static TenantRecordDto Tenant(Guid id) => + new(id, Name: "tenant", Status: "active", CreatedAt: DateTimeOffset.UtcNow); + + // Поднимает хост ингресса: реестр тенантов + tenant-адаптеры сценария (общий харнесс). + // registry: Реестр тенантов сценария. + // registerTenantServices: Дополнительные tenant-scoped адаптеры сценария + // (IPipelineStore/ISettingsStore/брокер). + // scenario: Сценарий с gRPC-каналом. + private static Task RunAsync( + FakeTenantRegistry registry, + Action registerTenantServices, + Func scenario) + => TelegramIngressTestHost.RunAsync( + ValidToken, + services => + { + services.AddSingleton(registry); + registerTenantServices(services); + }, + scenario); + + // Вызывает PushMessage с metadata сценария (deadline 10 с — контракт README). + // channel: Канал к хосту. + // request: Запрос PushMessage. + // tenantId: Id тенанта в metadata (null — без заголовка tenant-id). + // tokenHeader: Значение metadata «service-token» (null — без заголовка). + private static Task PushAsync(GrpcChannel channel, PushMessageRequest request, Guid? tenantId, string? tokenHeader) + { + var client = new IngressService.IngressServiceClient(channel); + AsyncUnaryCall call = client.PushMessageAsync( + request, + new CallOptions(TelegramIngressTestHost.CallMetadata(tokenHeader, tenantId), deadline: Deadline())); + return call.ResponseAsync; + } + + // Вызывает SyncDialogs с metadata сценария. + // channel: Канал к хосту. + // request: Запрос SyncDialogs. + // tenantId: Id тенанта в metadata (null — без заголовка tenant-id). + private static Task SyncAsync(GrpcChannel channel, SyncDialogsRequest request, Guid? tenantId) + { + var client = new IngressService.IngressServiceClient(channel); + AsyncUnaryCall call = client.SyncDialogsAsync( + request, + new CallOptions(TelegramIngressTestHost.CallMetadata(ValidToken, tenantId), deadline: Deadline())); + return call.ResponseAsync; + } + + // Вызывает ReportStatus с metadata сценария. + // client: Клиент ингресса. + // connected: Флаг connected репорта. + // phase: Фаза репорта. + // account: Аккаунт репорта. + // tenantId: Id тенанта в metadata (null — без заголовка tenant-id). + private static Task ReportAsync( + IngressService.IngressServiceClient client, + bool connected, + string phase, + string account, + Guid? tenantId) + { + var request = new ReportStatusRequest + { + Phase = phase, + Connected = connected, + Listener = true, + Account = account, + }; + AsyncUnaryCall call = client.ReportStatusAsync( + request, + new CallOptions(TelegramIngressTestHost.CallMetadata(ValidToken, tenantId), deadline: Deadline())); + return call.ResponseAsync; + } + + // Запрос PushMessage сценария (канальные поля фиксированы). + // text: Текст сообщения. + // msgId: Id сообщения в Telegram (null — без msg_id). + private static PushMessageRequest Request(string text, long? msgId = null) + { + var request = new PushMessageRequest + { + DialogId = DialogId, + ChannelName = "Канал", + ChannelHandle = "kanal_handle", + ChannelHue = "#a33", + Text = text, + }; + if (msgId is not null) + { + request.MsgId = msgId.Value; + } + + return request; + } + + // Deadline вызовов теста (контракт ингресса — 10 с). + private static DateTime Deadline() + => DateTime.UtcNow.AddSeconds(TelegramIngressTestHost.RpcDeadlineSeconds); + + // Читает события канала: текст тоста (поле text) либо JSON события для прочих типов. + // subscription: Подписка канала тенанта. + // eventType: Тип события. + private static List ReadEvents(SseSubscription subscription, string eventType) + { + var texts = new List(); + while (subscription.Events.TryRead(out SseEvent? sseEvent)) + { + if (sseEvent!.Type != eventType) + { + continue; + } + + using JsonDocument payload = JsonDocument.Parse(sseEvent.Json); + texts.Add(payload.RootElement.TryGetProperty("text", out JsonElement text) ? text.GetString()! : sseEvent.Json); + } + + return texts; + } + + // Проверяет сохранённый KV tgStatus (camelCase JSON): фаза и флаг connected. + // settings: KV-хранилище сценария. + // phase: Ожидаемая фаза. + // connected: Ожидаемый флаг connected. + private static void AssertStatusStored(FakeSettingsStore settings, string phase, bool connected) + { + string? json = settings.GetStoredJson(SettingsKeys.TgStatus); + Assert.NotNull(json); + using JsonDocument stored = JsonDocument.Parse(json!); + Assert.Equal(phase, stored.RootElement.GetProperty("phase").GetString()); + Assert.Equal(connected, stored.RootElement.GetProperty("connected").GetBoolean()); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/TelegramIngressTestHost.cs b/src/core/tests/Deal.Tests.Unit/TelegramIngressTestHost.cs index a5ca7fe..957467d 100644 --- a/src/core/tests/Deal.Tests.Unit/TelegramIngressTestHost.cs +++ b/src/core/tests/Deal.Tests.Unit/TelegramIngressTestHost.cs @@ -6,10 +6,20 @@ using Deal.Api.Telegram; using Deal.Contracts.Integrations; using Deal.Grpc.Telegram; using Deal.Infrastructure.Data; -using Deal.Modules.Pipeline.Application; -using Deal.Modules.Settings.Application; +using Deal.Modules.Pipeline.Application.Abstractions; +using Deal.Modules.Pipeline.Application.Models; +using Deal.Modules.Pipeline.Application.Registrars; +using Deal.Modules.Pipeline.Application.Services; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; using Deal.Modules.Telegram.Application; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants; using Grpc.Core; using Grpc.Net.Client; diff --git a/src/core/tests/Deal.Tests.Unit/TelegramKeysServiceTests.cs b/src/core/tests/Deal.Tests.Unit/TelegramKeysServiceTests.cs index e59384d..7db4fde 100644 --- a/src/core/tests/Deal.Tests.Unit/TelegramKeysServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/TelegramKeysServiceTests.cs @@ -1,5 +1,8 @@ using Deal.Api.Telegram; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/TenantAdminServiceTests.cs b/src/core/tests/Deal.Tests.Unit/TenantAdminServiceTests.cs index ba9766e..1237083 100644 --- a/src/core/tests/Deal.Tests.Unit/TenantAdminServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/TenantAdminServiceTests.cs @@ -1,5 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/TenantLimitStoreTests.cs b/src/core/tests/Deal.Tests.Unit/TenantLimitStoreTests.cs index 215fe98..27489c6 100644 --- a/src/core/tests/Deal.Tests.Unit/TenantLimitStoreTests.cs +++ b/src/core/tests/Deal.Tests.Unit/TenantLimitStoreTests.cs @@ -1,8 +1,11 @@ using Deal.Infrastructure.Persistence; using Deal.Infrastructure.Persistence.Entities; using Deal.Infrastructure.Persistence.Repositories; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Microsoft.EntityFrameworkCore; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/TenantRepositoryTests.cs b/src/core/tests/Deal.Tests.Unit/TenantRepositoryTests.cs index c595290..0737808 100644 --- a/src/core/tests/Deal.Tests.Unit/TenantRepositoryTests.cs +++ b/src/core/tests/Deal.Tests.Unit/TenantRepositoryTests.cs @@ -1,7 +1,10 @@ using Deal.Infrastructure.Persistence; using Deal.Infrastructure.Persistence.Repositories; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Microsoft.EntityFrameworkCore; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/TenantSchemaMigrationServiceTests.cs b/src/core/tests/Deal.Tests.Unit/TenantSchemaMigrationServiceTests.cs index 1b20103..11e8829 100644 --- a/src/core/tests/Deal.Tests.Unit/TenantSchemaMigrationServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/TenantSchemaMigrationServiceTests.cs @@ -1,94 +1,97 @@ -using Deal.Infrastructure.Tenancy; -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; -using Microsoft.Extensions.Logging.Abstractions; - -namespace Deal.Tests.Unit; - -/// -/// Тесты пакетной миграции схем тенантов (этап 12, пакет C): провижининг всех схем реестра, отказ одной -/// схемы не прерывает остальные, пустой реестр — корректная пустая сводка. -/// -/// -/// Реальный TenantProvisioningService требует Postgres, поэтому проверяется координация сервиса на -/// фейках (, ), зеркалящих порты. -/// Идемпотентность повторного прогона обеспечивает EF MigrateAsync (см. TenantProvisioningService). -/// -public sealed class TenantSchemaMigrationServiceTests -{ - // Тенант сценария A. - private static readonly Guid TenantA = Guid.Parse("11111111-1111-1111-1111-111111111111"); - - // Тенант сценария B. - private static readonly Guid TenantB = Guid.Parse("22222222-2222-2222-2222-222222222222"); - - [Fact] - public async Task MigrateAllAsync_EmptyRegistry_ReturnsEmptySummary() - { - var service = NewService(new FakeTenantRepository(), new FakeTenantProvisioner()); - - TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None); - - Assert.True(summary.Ok); - Assert.Equal(0, summary.Total); - Assert.Equal(0, summary.Migrated); - Assert.Empty(summary.FailedSchemas); - } - - [Fact] - public async Task MigrateAllAsync_ProvisionsEveryTenantSchema() - { - var provisioner = new FakeTenantProvisioner(); - var repository = new FakeTenantRepository( - TenantRecord(TenantA, "A"), - TenantRecord(TenantB, "B")); - var service = NewService(repository, provisioner); - - TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None); - - Assert.True(summary.Ok); - Assert.Equal(2, summary.Total); - Assert.Equal(2, summary.Migrated); - Assert.Equal(0, summary.Failed); - Assert.Equal( - new[] { $"tenant_{TenantA:N}", $"tenant_{TenantB:N}" }.OrderBy(name => name, StringComparer.Ordinal), - provisioner.ProvisionedSchemaNames.OrderBy(name => name, StringComparer.Ordinal)); - } - - [Fact] - public async Task MigrateAllAsync_OneSchemaFails_ContinuesAndReportsFailed() - { - var failingSchema = $"tenant_{TenantA:N}"; - var provisioner = new FailingTenantProvisioner(failingSchema); - var repository = new FakeTenantRepository( - TenantRecord(TenantA, "A"), - TenantRecord(TenantB, "B")); - var service = NewService(repository, provisioner); - - TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None); - - Assert.False(summary.Ok); - Assert.Equal(2, summary.Total); - Assert.Equal(1, summary.Migrated); - Assert.Equal(1, summary.Failed); - Assert.Equal(new[] { failingSchema }, summary.FailedSchemas); - // Уцелевшая схема мигрирована, сбойная — не попала в список успешных. - Assert.Equal(new[] { $"tenant_{TenantB:N}" }, provisioner.ProvisionedSchemaNames); - } - - // Создаёт сервис на фейках (логирование не проверяется). - // repository: Реестр тенантов. - // provisioner: Провижинер схем. - // Возвращает: Тестируемый сервис. - private static TenantSchemaMigrationService NewService( - ITenantRepository repository, - ITenantProvisioner provisioner) - => new(repository, provisioner, NullLogger.Instance); - - // Строка реестра тенантов для сценария. - // id: Id тенанта. - // name: Имя тенанта. - // Возвращает: Запись реестра. - private static TenantRecordDto TenantRecord(Guid id, string name) - => new(id, name, TenantStatuses.Active, DateTimeOffset.UtcNow); -} +using Deal.Infrastructure.Tenancy; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; +using Microsoft.Extensions.Logging.Abstractions; + +namespace Deal.Tests.Unit; + +/// +/// Тесты пакетной миграции схем тенантов (этап 12, пакет C): провижининг всех схем реестра, отказ одной +/// схемы не прерывает остальные, пустой реестр — корректная пустая сводка. +/// +/// +/// Реальный TenantProvisioningService требует Postgres, поэтому проверяется координация сервиса на +/// фейках (, ), зеркалящих порты. +/// Идемпотентность повторного прогона обеспечивает EF MigrateAsync (см. TenantProvisioningService). +/// +public sealed class TenantSchemaMigrationServiceTests +{ + // Тенант сценария A. + private static readonly Guid TenantA = Guid.Parse("11111111-1111-1111-1111-111111111111"); + + // Тенант сценария B. + private static readonly Guid TenantB = Guid.Parse("22222222-2222-2222-2222-222222222222"); + + [Fact] + public async Task MigrateAllAsync_EmptyRegistry_ReturnsEmptySummary() + { + var service = NewService(new FakeTenantRepository(), new FakeTenantProvisioner()); + + TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None); + + Assert.True(summary.Ok); + Assert.Equal(0, summary.Total); + Assert.Equal(0, summary.Migrated); + Assert.Empty(summary.FailedSchemas); + } + + [Fact] + public async Task MigrateAllAsync_ProvisionsEveryTenantSchema() + { + var provisioner = new FakeTenantProvisioner(); + var repository = new FakeTenantRepository( + TenantRecord(TenantA, "A"), + TenantRecord(TenantB, "B")); + var service = NewService(repository, provisioner); + + TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None); + + Assert.True(summary.Ok); + Assert.Equal(2, summary.Total); + Assert.Equal(2, summary.Migrated); + Assert.Equal(0, summary.Failed); + Assert.Equal( + new[] { $"tenant_{TenantA:N}", $"tenant_{TenantB:N}" }.OrderBy(name => name, StringComparer.Ordinal), + provisioner.ProvisionedSchemaNames.OrderBy(name => name, StringComparer.Ordinal)); + } + + [Fact] + public async Task MigrateAllAsync_OneSchemaFails_ContinuesAndReportsFailed() + { + var failingSchema = $"tenant_{TenantA:N}"; + var provisioner = new FailingTenantProvisioner(failingSchema); + var repository = new FakeTenantRepository( + TenantRecord(TenantA, "A"), + TenantRecord(TenantB, "B")); + var service = NewService(repository, provisioner); + + TenantMigrationSummary summary = await service.MigrateAllAsync(CancellationToken.None); + + Assert.False(summary.Ok); + Assert.Equal(2, summary.Total); + Assert.Equal(1, summary.Migrated); + Assert.Equal(1, summary.Failed); + Assert.Equal(new[] { failingSchema }, summary.FailedSchemas); + // Уцелевшая схема мигрирована, сбойная — не попала в список успешных. + Assert.Equal(new[] { $"tenant_{TenantB:N}" }, provisioner.ProvisionedSchemaNames); + } + + // Создаёт сервис на фейках (логирование не проверяется). + // repository: Реестр тенантов. + // provisioner: Провижинер схем. + // Возвращает: Тестируемый сервис. + private static TenantSchemaMigrationService NewService( + ITenantRepository repository, + ITenantProvisioner provisioner) + => new(repository, provisioner, NullLogger.Instance); + + // Строка реестра тенантов для сценария. + // id: Id тенанта. + // name: Имя тенанта. + // Возвращает: Запись реестра. + private static TenantRecordDto TenantRecord(Guid id, string name) + => new(id, name, TenantStatuses.Active, DateTimeOffset.UtcNow); +} diff --git a/src/core/tests/Deal.Tests.Unit/TgStatusServiceTests.cs b/src/core/tests/Deal.Tests.Unit/TgStatusServiceTests.cs index abc21f4..23dfef4 100644 --- a/src/core/tests/Deal.Tests.Unit/TgStatusServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/TgStatusServiceTests.cs @@ -1,6 +1,9 @@ using Deal.Api.Telegram; using Deal.Contracts.Integrations.Models; -using Deal.Modules.Settings.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; using Deal.Modules.Telegram.Application; using Deal.Modules.Telegram.Application.Models; using Grpc.Core; diff --git a/src/core/tests/Deal.Tests.Unit/TokenBudgetServiceTests.cs b/src/core/tests/Deal.Tests.Unit/TokenBudgetServiceTests.cs index aab8425..29fc2ac 100644 --- a/src/core/tests/Deal.Tests.Unit/TokenBudgetServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/TokenBudgetServiceTests.cs @@ -1,5 +1,8 @@ -using Deal.Modules.Tenants.Application; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; namespace Deal.Tests.Unit; diff --git a/src/core/tests/Deal.Tests.Unit/TokenUsageEventServiceTests.cs b/src/core/tests/Deal.Tests.Unit/TokenUsageEventServiceTests.cs index df49d2b..25769e7 100644 --- a/src/core/tests/Deal.Tests.Unit/TokenUsageEventServiceTests.cs +++ b/src/core/tests/Deal.Tests.Unit/TokenUsageEventServiceTests.cs @@ -1,49 +1,52 @@ -using Deal.Modules.Tenants.Application; -using Deal.Modules.Tenants.Application.Models; - -namespace Deal.Tests.Unit; - -/// -/// Тесты сервиса истории расхода токенов (этап 10, T2): единая точка записи (At=UTC-now) и чтения агрегатов. -/// -public sealed class TokenUsageEventServiceTests -{ - private static readonly Guid TenantId = Guid.NewGuid(); - - [Fact] - public async Task AppendAsync_StampsAtWithUtcNow() - { - var store = new FakeTokenUsageEventStore(); - var service = new TokenUsageEventService(store); - - DateTimeOffset before = DateTimeOffset.UtcNow; - await service.AppendAsync( - new TokenUsageEventDto( - TenantId, At: default, Provider: "deepseek", Model: "m", Kind: TokenUsageEventKinds.Ai, - PromptTokens: 1, CompletionTokens: 2, TotalTokens: 3, DetailJson: null), - CancellationToken.None); - - TokenUsageEventDto recorded = Assert.Single(store.Records); - Assert.True(recorded.At >= before); - Assert.True(recorded.At <= DateTimeOffset.UtcNow); - } - - [Fact] - public async Task AggregateAsync_ProxiesStoreGrouping() - { - var store = new FakeTokenUsageEventStore(); - var service = new TokenUsageEventService(store); - await store.AppendAsync( - new TokenUsageEventDto( - TenantId, DateTimeOffset.UtcNow, "deepseek", "m", TokenUsageEventKinds.Ai, 10, 5, 15, null), - CancellationToken.None); - - IReadOnlyList rows = await service.AggregateAsync( - new TokenUsageEventQueryDto(null, null, null, null, null, null, TokenUsageGroupBys.Provider), - CancellationToken.None); - - TokenUsageAggregateDto row = Assert.Single(rows); - Assert.Equal("deepseek", row.Key); - Assert.Equal(15, row.TotalTokens); - } -} +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; +using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; + +namespace Deal.Tests.Unit; + +/// +/// Тесты сервиса истории расхода токенов (этап 10, T2): единая точка записи (At=UTC-now) и чтения агрегатов. +/// +public sealed class TokenUsageEventServiceTests +{ + private static readonly Guid TenantId = Guid.NewGuid(); + + [Fact] + public async Task AppendAsync_StampsAtWithUtcNow() + { + var store = new FakeTokenUsageEventStore(); + var service = new TokenUsageEventService(store); + + DateTimeOffset before = DateTimeOffset.UtcNow; + await service.AppendAsync( + new TokenUsageEventDto( + TenantId, At: default, Provider: "deepseek", Model: "m", Kind: TokenUsageEventKinds.Ai, + PromptTokens: 1, CompletionTokens: 2, TotalTokens: 3, DetailJson: null), + CancellationToken.None); + + TokenUsageEventDto recorded = Assert.Single(store.Records); + Assert.True(recorded.At >= before); + Assert.True(recorded.At <= DateTimeOffset.UtcNow); + } + + [Fact] + public async Task AggregateAsync_ProxiesStoreGrouping() + { + var store = new FakeTokenUsageEventStore(); + var service = new TokenUsageEventService(store); + await store.AppendAsync( + new TokenUsageEventDto( + TenantId, DateTimeOffset.UtcNow, "deepseek", "m", TokenUsageEventKinds.Ai, 10, 5, 15, null), + CancellationToken.None); + + IReadOnlyList rows = await service.AggregateAsync( + new TokenUsageEventQueryDto(null, null, null, null, null, null, TokenUsageGroupBys.Provider), + CancellationToken.None); + + TokenUsageAggregateDto row = Assert.Single(rows); + Assert.Equal("deepseek", row.Key); + Assert.Equal(15, row.TotalTokens); + } +} diff --git a/src/core/tests/Deal.Tests.Unit/TokenUsageRecorderTests.cs b/src/core/tests/Deal.Tests.Unit/TokenUsageRecorderTests.cs index 0441a11..f8c472b 100644 --- a/src/core/tests/Deal.Tests.Unit/TokenUsageRecorderTests.cs +++ b/src/core/tests/Deal.Tests.Unit/TokenUsageRecorderTests.cs @@ -2,9 +2,15 @@ using System.Text.Json; using Deal.Grpc.Ai; using Deal.Infrastructure.Data; using Deal.Infrastructure.Integrations; -using Deal.Modules.Settings.Application; -using Deal.Modules.Tenants.Application; +using Deal.Modules.Settings.Application.Abstractions; +using Deal.Modules.Settings.Application.Models; +using Deal.Modules.Settings.Application.Registrars; +using Deal.Modules.Settings.Application.Services; +using Deal.Modules.Tenants.Application.Abstractions; +using Deal.Modules.Tenants.Application.Extensions; using Deal.Modules.Tenants.Application.Models; +using Deal.Modules.Tenants.Application.Registrars; +using Deal.Modules.Tenants.Application.Services; using Deal.SharedKernel.Tenants; namespace Deal.Tests.Unit;