< Summary

Information
Class: Services.PartnerServices.PartnerManagementService
Assembly: Services
File(s): /home/runner/work/Studio2Prod/Studio2Prod/AppRepository/PartnerServices/PartnerManagementService.cs
Line coverage
86%
Covered lines: 171
Uncovered lines: 27
Coverable lines: 198
Total lines: 345
Line coverage: 86.3%
Branch coverage
76%
Covered branches: 135
Total branches: 176
Branch coverage: 76.7%
Method coverage

Feature is only available for sponsors

Upgrade to PRO version

Metrics

MethodBranch coverage Crap Score Cyclomatic complexity Line coverage
.ctor(...)100%11100%
ListAsync()100%210%
GetAsync()0%620%
CreateAsync()79.16%242492%
UpdateAsync()70.96%686288.46%
SetPermissionsAsync()75%8888.88%
SaveRulesAsync()83.33%66100%
DeleteAsync()50%22100%
ValidateRules(...)85.71%434293.1%
RuleDisplayName(...)75%44100%
ValidatePeriod(...)95%2020100%
NormalizeGoogleEmail(...)75%44100%
IsValidEmail(...)50%44100%
RotateSecurityStamp(...)100%11100%
LoadAsync(...)50%22100%
ToDetails(...)50%22100%

File(s)

/home/runner/work/Studio2Prod/Studio2Prod/AppRepository/PartnerServices/PartnerManagementService.cs

#LineLine coverage
 1using System.Globalization;
 2using System.Net.Mail;
 3using System.Text.Json;
 4using System.Text.RegularExpressions;
 5using Microsoft.EntityFrameworkCore;
 6using OzonDomains.Models.Partners;
 7using OzonRepositories.Context;
 8
 9namespace Services.PartnerServices;
 10
 11public interface IPartnerManagementService
 12{
 13    Task<IReadOnlyList<PartnerListItemDto>> ListAsync(CancellationToken ct = default);
 14
 15    Task<PartnerDetailsDto?> GetAsync(long id, CancellationToken ct = default);
 16
 17    Task<PartnerOpResult<PartnerDetailsDto>> CreateAsync(CreatePartnerRequest request, string ownerId, CancellationToken
 18
 19    Task<PartnerOpResult<PartnerDetailsDto>> UpdateAsync(long id, UpdatePartnerRequest request, string ownerId, Cancella
 20
 21    Task<PartnerOpResult<PartnerDetailsDto>> SetPermissionsAsync(long id, IReadOnlyCollection<PartnerPermissionCode> per
 22
 23    /// <summary>Полностью заменяет набор правил партнёра.</summary>
 24    Task<PartnerOpResult<PartnerDetailsDto>> SaveRulesAsync(long id, IReadOnlyList<PartnerRuleDto> rules, string ownerId
 25
 26    Task<PartnerOpResult<bool>> DeleteAsync(long id, string ownerId, CancellationToken ct = default);
 27}
 28
 4329public sealed partial class PartnerManagementService(
 4330    ApplicationDbContext db,
 4331    IPartnerAuditService audit) : IPartnerManagementService
 32{
 33    internal const int MaxRulesPerPartner = 50;
 34    internal const int MaxValuesPerCondition = 500;
 35
 36
 37    public async Task<IReadOnlyList<PartnerListItemDto>> ListAsync(CancellationToken ct = default)
 38    {
 039        var partners = await db.Partners.AsNoTracking()
 040            .Include(x => x.Permissions)
 041            .OrderBy(x => x.Name)
 042            .ToListAsync(ct);
 043        var ruleCounts = await db.PartnerRules.AsNoTracking()
 044            .GroupBy(x => x.PartnerId)
 045            .Select(g => new { PartnerId = g.Key, Count = g.Count() })
 46            .ToDictionaryAsync(x => x.PartnerId, x => x.Count, ct);
 47
 048        return partners
 049            .Select(p => new PartnerListItemDto(
 050                p.Id, p.Name, p.Login, p.Email, p.GoogleEmail, p.IsActive, p.LastLoginAtUtc,
 051                ruleCounts.GetValueOrDefault(p.Id),
 52                p.Permissions.Select(x => x.Code).OrderBy(x => x).ToList()))
 053            .ToList();
 054    }
 55
 56    public async Task<PartnerDetailsDto?> GetAsync(long id, CancellationToken ct = default)
 57    {
 058        var partner = await LoadAsync(id, tracking: false, ct);
 059        return partner is null ? null : ToDetails(partner);
 060    }
 61
 62    public async Task<PartnerOpResult<PartnerDetailsDto>> CreateAsync(
 63        CreatePartnerRequest request, string ownerId, CancellationToken ct = default)
 64    {
 4465        var name = (request.Name ?? string.Empty).Trim();
 4466        if (name.Length is 0 or > 200)
 067            return PartnerOpResult<PartnerDetailsDto>.Fail("Название обязательно, до 200 символов.");
 68
 4469        var email = PartnerEmail.Normalize(request.Email);
 4470        if (email.Length == 0)
 271            return PartnerOpResult<PartnerDetailsDto>.Fail("Укажите почту партнёра: на неё придёт приглашение.");
 4272        if (!PartnerEmail.IsValid(email))
 573            return PartnerOpResult<PartnerDetailsDto>.Fail("Некорректная почта партнёра.");
 74
 3775        var (googleEmail, googleError) = NormalizeGoogleEmail(request.GoogleEmail);
 3776        if (googleError is not null)
 077            return PartnerOpResult<PartnerDetailsDto>.Fail(googleError);
 78
 3779        if (await db.Partners.AnyAsync(x => x.Email == email, ct))
 180            return PartnerOpResult<PartnerDetailsDto>.Fail("Партнёр с такой почтой уже есть.");
 3681        if (googleEmail is not null && await db.Partners.AnyAsync(x => x.GoogleEmail == googleEmail, ct))
 182            return PartnerOpResult<PartnerDetailsDto>.Fail("Эта Google-почта уже привязана к другому партнёру.");
 83
 84        // Логин больше не нужен для входа, но остаётся уникальным идентификатором для журнала.
 3585        var login = email.Length <= 100 && !await db.Partners.AnyAsync(x => x.Login == email, ct)
 3586            ? email
 3587            : $"partner-{Guid.NewGuid():N}";
 3588        var partner = new Partner { Name = name, Login = login, Email = email, GoogleEmail = googleEmail, PasswordHash =
 3589        db.Partners.Add(partner);
 3590        await db.SaveChangesAsync(ct);
 91
 3592        await audit.WriteAsync("partner_created", PartnerActorKind.Owner, ownerId, partner.Id,
 3593            details: $"Почта: {partner.Email}", ct: ct);
 3594        return PartnerOpResult<PartnerDetailsDto>.Success(ToDetails(partner));
 4495    }
 96
 97    public async Task<PartnerOpResult<PartnerDetailsDto>> UpdateAsync(
 98        long id, UpdatePartnerRequest request, string ownerId, CancellationToken ct = default)
 99    {
 8100        var partner = await LoadAsync(id, tracking: true, ct);
 8101        if (partner is null) return PartnerOpResult<PartnerDetailsDto>.Fail("Партнёр не найден.");
 102
 8103        var name = (request.Name ?? string.Empty).Trim();
 8104        if (name.Length is 0 or > 200)
 0105            return PartnerOpResult<PartnerDetailsDto>.Fail("Название обязательно, до 200 символов.");
 106
 8107        var email = PartnerEmail.Normalize(request.Email);
 8108        if (email.Length == 0)
 109        {
 110            // Почту нельзя убрать у партнёра, у которого она уже есть: иначе он потеряет вход без предупреждения.
 2111            if (partner.Email is not null)
 1112                return PartnerOpResult<PartnerDetailsDto>.Fail("Почту нельзя очистить: на неё приходят коды входа.");
 113        }
 114        else
 115        {
 6116            if (!PartnerEmail.IsValid(email))
 0117                return PartnerOpResult<PartnerDetailsDto>.Fail("Некорректная почта партнёра.");
 6118            if (email != partner.Email && await db.Partners.AnyAsync(x => x.Email == email && x.Id != id, ct))
 1119                return PartnerOpResult<PartnerDetailsDto>.Fail("Партнёр с такой почтой уже есть.");
 120        }
 121
 6122        var (googleEmail, googleError) = NormalizeGoogleEmail(request.GoogleEmail);
 6123        if (googleError is not null)
 0124            return PartnerOpResult<PartnerDetailsDto>.Fail(googleError);
 6125        if (googleEmail is not null && await db.Partners.AnyAsync(x => x.GoogleEmail == googleEmail && x.Id != id, ct))
 0126            return PartnerOpResult<PartnerDetailsDto>.Fail("Эта Google-почта уже привязана к другому партнёру.");
 127
 6128        var notificationEmail = string.IsNullOrWhiteSpace(request.NotificationEmail)
 6129            ? null
 6130            : request.NotificationEmail.Trim();
 6131        if (notificationEmail is not null && !IsValidEmail(notificationEmail))
 0132            return PartnerOpResult<PartnerDetailsDto>.Fail("Некорректный email для уведомлений.");
 6133        if (request.NotificationChannels.HasFlag(PartnerNotificationChannels.Email) && notificationEmail is null)
 1134            return PartnerOpResult<PartnerDetailsDto>.Fail("Для канала Email укажите адрес уведомлений.");
 135
 5136        var telegramChatId = string.IsNullOrWhiteSpace(request.TelegramChatId) ? null : request.TelegramChatId.Trim();
 5137        if (request.NotificationChannels.HasFlag(PartnerNotificationChannels.Telegram) && telegramChatId is null)
 0138            return PartnerOpResult<PartnerDetailsDto>.Fail("Для канала Telegram укажите chat id.");
 139
 5140        var wasActive = partner.IsActive;
 5141        var oldEmail = partner.Email;
 5142        var emailChanged = email.Length > 0 && email != oldEmail;
 5143        partner.Name = name;
 6144        if (emailChanged) partner.Email = email;
 5145        partner.GoogleEmail = googleEmail;
 5146        partner.IsActive = request.IsActive;
 5147        partner.NotificationChannels = request.NotificationChannels;
 5148        partner.NotificationEmail = notificationEmail;
 5149        partner.TelegramChatId = telegramChatId;
 150        // Блокировка и смена почты обрывают действующие сессии и делают недействительными выданные коды.
 9151        if ((wasActive && !request.IsActive) || emailChanged) RotateSecurityStamp(partner);
 5152        if (emailChanged)
 153        {
 1154            var open = await db.PartnerLoginCodes
 1155                .Where(x => x.PartnerId == id && x.ConsumedAtUtc == null)
 1156                .ToListAsync(ct);
 5157            foreach (var code in open) code.ConsumedAtUtc = DateTime.UtcNow;
 158        }
 159
 5160        await db.SaveChangesAsync(ct);
 5161        if (emailChanged)
 1162            await audit.WriteAsync("partner_email_changed", PartnerActorKind.Owner, ownerId, partner.Id,
 1163                details: $"{oldEmail ?? "(не было)"} → {partner.Email}", ct: ct);
 5164        await audit.WriteAsync(wasActive == request.IsActive ? "partner_updated" : request.IsActive ? "partner_enabled" 
 5165            PartnerActorKind.Owner, ownerId, partner.Id, ct: ct);
 5166        return PartnerOpResult<PartnerDetailsDto>.Success(ToDetails(partner));
 8167    }
 168
 169    public async Task<PartnerOpResult<PartnerDetailsDto>> SetPermissionsAsync(
 170        long id, IReadOnlyCollection<PartnerPermissionCode> permissions, string ownerId, CancellationToken ct = default)
 171    {
 3172        var partner = await LoadAsync(id, tracking: true, ct);
 3173        if (partner is null) return PartnerOpResult<PartnerDetailsDto>.Fail("Партнёр не найден.");
 174
 3175        var wanted = permissions.Distinct().ToHashSet();
 176        if (wanted.Any(x => !Enum.IsDefined(x)))
 0177            return PartnerOpResult<PartnerDetailsDto>.Fail("Неизвестная возможность.");
 178
 5179        partner.Permissions.RemoveAll(x => !wanted.Contains(x.Code));
 19180        foreach (var code in wanted.Where(c => partner.Permissions.All(x => x.Code != c)))
 4181            partner.Permissions.Add(new PartnerPermission { Code = code });
 182
 3183        await db.SaveChangesAsync(ct);
 3184        await audit.WriteAsync("permissions_saved", PartnerActorKind.Owner, ownerId, partner.Id,
 185            details: string.Join(", ", wanted.OrderBy(x => x)), ct: ct);
 3186        return PartnerOpResult<PartnerDetailsDto>.Success(ToDetails(partner));
 3187    }
 188
 189    public async Task<PartnerOpResult<PartnerDetailsDto>> SaveRulesAsync(
 190        long id, IReadOnlyList<PartnerRuleDto> rules, string ownerId, CancellationToken ct = default)
 191    {
 11192        var partner = await LoadAsync(id, tracking: true, ct);
 11193        if (partner is null) return PartnerOpResult<PartnerDetailsDto>.Fail("Партнёр не найден.");
 194
 11195        var error = ValidateRules(rules);
 18196        if (error is not null) return PartnerOpResult<PartnerDetailsDto>.Fail(error);
 197
 4198        partner.Rules.Clear();
 20199        for (var i = 0; i < rules.Count; i++)
 200        {
 6201            var rule = rules[i];
 6202            partner.Rules.Add(new PartnerRule
 6203            {
 6204                Name = RuleDisplayName(rule.Name, i),
 6205                IsActive = rule.IsActive,
 6206                Conditions = rule.Conditions
 207                    .Select(c => new PartnerRuleCondition
 208                    {
 209                        Field = c.Field,
 210                        ValuesJson = JsonSerializer.Serialize(c.Values.Select(v => v.Trim()).ToList())
 211                    })
 6212                    .ToList()
 6213            });
 214        }
 215
 4216        await db.SaveChangesAsync(ct);
 4217        await audit.WriteAsync("rules_saved", PartnerActorKind.Owner, ownerId, partner.Id,
 4218            details: $"Правил: {rules.Count}", ct: ct);
 4219        return PartnerOpResult<PartnerDetailsDto>.Success(ToDetails(partner));
 11220    }
 221
 222    public async Task<PartnerOpResult<bool>> DeleteAsync(long id, string ownerId, CancellationToken ct = default)
 223    {
 1224        var partner = await db.Partners.FirstOrDefaultAsync(x => x.Id == id, ct);
 1225        if (partner is null) return PartnerOpResult<bool>.Fail("Партнёр не найден.");
 226
 1227        var login = partner.Login;
 1228        db.Partners.Remove(partner);
 1229        await db.SaveChangesAsync(ct);
 230        // Запись остаётся в журнале без внешнего ключа: история действий партнёра сохраняется.
 1231        await audit.WriteAsync("partner_deleted", PartnerActorKind.Owner, ownerId, id,
 1232            details: $"Логин: {login}", ct: ct);
 1233        return PartnerOpResult<bool>.Success(true);
 1234    }
 235
 236    public static string? ValidateRules(IReadOnlyList<PartnerRuleDto> rules)
 237    {
 16238        if (rules.Count > MaxRulesPerPartner)
 0239            return $"Правил не больше {MaxRulesPerPartner}.";
 240
 46241        for (var i = 0; i < rules.Count; i++)
 242        {
 18243            var rule = rules[i];
 18244            var name = RuleDisplayName(rule.Name, i);
 19245            if (name.Length > 200) return "Название правила не длиннее 200 символов.";
 17246            if (rule.Conditions.Count == 0)
 1247                return $"Правило «{name}»: добавьте хотя бы одно условие. Правило без условий открыло бы все заказы.";
 248
 53249            var duplicate = rule.Conditions.GroupBy(c => c.Field).FirstOrDefault(g => g.Count() > 1);
 16250            if (duplicate is not null)
 1251                return $"Правило «{name}»: поле «{duplicate.Key}» указано дважды, объедините значения в одно условие.";
 252
 56253            foreach (var condition in rule.Conditions)
 254            {
 17255                if (!Enum.IsDefined(condition.Field)) return $"Правило «{name}»: неизвестное поле.";
 40256                var values = condition.Values.Select(v => (v ?? string.Empty).Trim()).ToList();
 17257                if (values.Count > MaxValuesPerCondition)
 0258                    return $"Правило «{name}»: не больше {MaxValuesPerCondition} значений в условии.";
 259
 17260                if (condition.Field == PartnerRuleField.DatePeriod)
 261                {
 4262                    var periodError = ValidatePeriod(name, values);
 7263                    if (periodError is not null) return periodError;
 264                }
 28265                else if (values.Count == 0 || values.Any(v => v.Length is 0 or > 200))
 266                {
 1267                    return $"Правило «{name}»: у поля «{condition.Field}» нужны непустые значения до 200 символов.";
 268                }
 12269                else if (condition.Field is PartnerRuleField.Status or PartnerRuleField.Warehouse or PartnerRuleField.Cl
 22270                         && values.Any(v => !int.TryParse(v, NumberStyles.Integer, CultureInfo.InvariantCulture, out _))
 271                {
 2272                    return $"Правило «{name}»: значения поля «{condition.Field}» — числовые идентификаторы.";
 273                }
 10274                else if (condition.Field == PartnerRuleField.Marketplace
 15275                         && values.Any(v => !Enum.TryParse<OzonDomains.ClientType>(v, true, out var t) || t == OzonDomai
 276                {
 2277                    return $"Правило «{name}»: маркетплейс — OZON или YANDEX.";
 278                }
 279            }
 280        }
 281
 5282        return null;
 8283    }
 284
 285    /// <summary>Название правила необязательно: пустое получает «Правило N» по порядку.</summary>
 286    internal static string RuleDisplayName(string? name, int index)
 287    {
 24288        var trimmed = (name ?? string.Empty).Trim();
 24289        return trimmed.Length == 0 ? $"Правило {index + 1}" : trimmed;
 290    }
 291
 292    private static string? ValidatePeriod(string ruleName, List<string> values)
 293    {
 9294        if (values.Count != 2 || values.All(v => v.Length == 0))
 1295            return $"Правило «{ruleName}»: период задаётся двумя значениями «с» и «по», хотя бы одно заполнено.";
 296
 6297        DateTime? from = null, to = null;
 14298        for (var i = 0; i < 2; i++)
 299        {
 5300            if (values[i].Length == 0) continue;
 4301            if (!DateTime.TryParseExact(values[i], "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.None, out 
 1302                return $"Правило «{ruleName}»: дата «{values[i]}» не в формате гггг-мм-дд.";
 6303            if (i == 0) from = d; else to = d;
 304        }
 305
 2306        return from is not null && to is not null && from > to
 2307            ? $"Правило «{ruleName}»: начало периода позже конца."
 2308            : null;
 309    }
 310
 311    private static (string? Email, string? Error) NormalizeGoogleEmail(string? raw)
 312    {
 83313        if (string.IsNullOrWhiteSpace(raw)) return (null, null);
 3314        var email = raw.Trim().ToLowerInvariant();
 3315        return IsValidEmail(email) ? (email, null) : (null, "Некорректная Google-почта.");
 316    }
 317
 318    private static bool IsValidEmail(string value)
 319    {
 3320        if (value.Length > 320) return false;
 3321        return MailAddress.TryCreate(value, out var addr) && addr.Address == value;
 322    }
 323
 4324    private static void RotateSecurityStamp(Partner partner) => partner.SecurityStamp = Guid.NewGuid().ToString("N");
 325
 326    private Task<Partner?> LoadAsync(long id, bool tracking, CancellationToken ct)
 327    {
 22328        IQueryable<Partner> q = db.Partners
 22329            .Include(x => x.Permissions)
 22330            .Include(x => x.Rules).ThenInclude(r => r.Conditions)
 22331            .AsSplitQuery();
 22332        if (!tracking) q = q.AsNoTracking();
 22333        return q.FirstOrDefaultAsync(x => x.Id == id, ct);
 334    }
 335
 47336    private static PartnerDetailsDto ToDetails(Partner p) => new(
 47337        p.Id, p.Name, p.Login, p.Email, p.GoogleEmail, p.IsActive, p.CreatedAtUtc, p.LastLoginAtUtc,
 47338        p.NotificationChannels, p.NotificationEmail, p.TelegramChatId,
 10339        p.Permissions.Select(x => x.Code).OrderBy(x => x).ToList(),
 12340        p.Rules.OrderBy(r => r.Id).Select(r => new PartnerRuleDto(
 53341            r.Id, r.Name, r.IsActive,
 14342            r.Conditions.OrderBy(c => c.Field).Select(c => new PartnerRuleConditionDto(
 60343                c.Field,
 60344                JsonSerializer.Deserialize<List<string>>(c.ValuesJson) ?? new List<string>())).ToList())).ToList());
 345}