diff --git a/src/food-market.api/Infrastructure/Validation/ModelErrorTranslator.cs b/src/food-market.api/Infrastructure/Validation/ModelErrorTranslator.cs new file mode 100644 index 0000000..d2e4eb7 --- /dev/null +++ b/src/food-market.api/Infrastructure/Validation/ModelErrorTranslator.cs @@ -0,0 +1,123 @@ +using Microsoft.AspNetCore.Mvc.ModelBinding; + +namespace foodmarket.Api.Infrastructure.Validation; + +/// Переводит технические ошибки разбора тела запроса (model binding / +/// JSON-десериализация) в дружелюбный русский. ASP.NET для неверного типа/enum +/// отдаёт тексты вида «The JSON value could not be converted to +/// foodmarket.Api.Controllers.Inventory.LossesController+LossInput. Path: $.reason …» +/// — показывать их пользователю нельзя (имена классов, пути, англ.). Заменяем на +/// «Неверное значение поля «Причина»». Сообщения FluentValidation (уже осмысленные, +/// русские) оставляем как есть. +public static class ModelErrorTranslator +{ + // Маркеры технических сообщений от System.Text.Json / model binder. + private static readonly string[] TechnicalMarkers = + { + "could not be converted", + "JSON value", + "is not a valid", + "could not be parsed", + "The input was not valid", + "The supplied value is invalid", + "is not valid for", + "Cannot deserialize", + "A non-empty request body is required", + "The dto field is required", + "could not convert", + "Could not convert", + }; + + // Человеческие названия частых полей (ключ — последний сегмент пути, lower). + private static readonly IReadOnlyDictionary FieldLabels = + new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["reason"] = "Причина", + ["quantity"] = "Количество", + ["qty"] = "Количество", + ["unitcost"] = "Себестоимость", + ["unitprice"] = "Цена", + ["price"] = "Цена", + ["amount"] = "Сумма", + ["sum"] = "Сумма", + ["date"] = "Дата", + ["storeid"] = "Склад", + ["productid"] = "Товар", + ["counterpartyid"] = "Контрагент", + ["customerid"] = "Покупатель", + ["supplierid"] = "Поставщик", + ["currencyid"] = "Валюта", + ["discount"] = "Скидка", + ["vatpercent"] = "НДС", + ["vat"] = "НДС", + ["payment"] = "Оплата", + ["paidcash"] = "Оплата наличными", + ["paidcard"] = "Оплата картой", + ["type"] = "Тип", + ["status"] = "Статус", + ["packaging"] = "Фасовка", + ["kind"] = "Вид", + ["email"] = "Email", + ["phone"] = "Телефон", + ["name"] = "Название", + ["article"] = "Артикул", + ["barcode"] = "Штрихкод", + ["code"] = "Код", + ["plan"] = "Тариф", + ["role"] = "Роль", + ["lines"] = "Позиции", + ["items"] = "Позиции", + }; + + private static bool IsTechnical(string msg) + => !string.IsNullOrEmpty(msg) && TechnicalMarkers.Any(m => msg.Contains(m, StringComparison.OrdinalIgnoreCase)); + + /// Человеческое имя поля из ключа ModelState («$.reason», «lines[0].reason», + /// «dto.Amount» → «Причина» / «Сумма»; неизвестное — очищенный ключ в кавычках). + public static string FieldLabel(string key) + { + if (string.IsNullOrWhiteSpace(key) || key == "$") return "данные запроса"; + var seg = key.TrimStart('$', '.'); + // последний сегмент пути без индексов массива + var lastDot = seg.LastIndexOf('.'); + if (lastDot >= 0) seg = seg[(lastDot + 1)..]; + var bracket = seg.IndexOf('['); + if (bracket >= 0) seg = seg[..bracket]; + seg = seg.Trim(); + if (seg.Length == 0) return "данные запроса"; + return FieldLabels.TryGetValue(seg, out var label) ? label : seg; + } + + /// Переводит одно сообщение. Техническое → «Неверное значение поля «…»»; + /// осмысленное (FluentValidation) — без изменений. + public static string Translate(string key, string message) + => IsTechnical(message) + ? $"Неверное значение поля «{FieldLabel(key)}»" + : message; + + /// Строит словарь дружелюбных ошибок по полям (ключ — очищенное имя поля) + /// из ModelState. Технические сообщения переведены, имена классов/пути убраны. + public static Dictionary Humanize(ModelStateDictionary modelState) + { + var result = new Dictionary(); + foreach (var kv in modelState) + { + if (kv.Value is null || kv.Value.Errors.Count == 0) continue; + var field = CleanFieldKey(kv.Key); + var msgs = kv.Value.Errors + .Select(e => Translate(kv.Key, string.IsNullOrWhiteSpace(e.ErrorMessage) + ? $"Неверное значение поля «{FieldLabel(kv.Key)}»" : e.ErrorMessage)) + .Distinct() + .ToArray(); + result[field] = msgs; + } + return result; + } + + private static string CleanFieldKey(string key) + { + if (string.IsNullOrWhiteSpace(key) || key == "$") return ""; + var seg = key.TrimStart('$', '.'); + return seg.Length == 0 ? "" : seg; + } +} diff --git a/src/food-market.api/Program.cs b/src/food-market.api/Program.cs index 378dc7c..5663d6b 100644 --- a/src/food-market.api/Program.cs +++ b/src/food-market.api/Program.cs @@ -337,20 +337,25 @@ static string ApplyDefaultPoolConfig(string? raw) // «за какое поле отвалилось» без детализации в теле ответа. o.InvalidModelStateResponseFactory = ctx => { - var problems = ctx.ModelState + // Технический лог — с сырыми путями/классами (видит только бэкенд). + var raw = ctx.ModelState .Where(kv => kv.Value?.Errors.Count > 0) .Select(kv => $"{kv.Key}: {string.Join(", ", kv.Value!.Errors.Select(e => e.ErrorMessage))}") .ToList(); ctx.HttpContext.RequestServices.GetRequiredService() .CreateLogger("ModelValidation") .LogWarning("[Проверка] {Path}: {Problems}", - ctx.HttpContext.Request.Path, string.Join("; ", problems)); - var pd = new Microsoft.AspNetCore.Mvc.ValidationProblemDetails(ctx.ModelState) + ctx.HttpContext.Request.Path, string.Join("; ", raw)); + + // Пользователю — дружелюбный русский без имён классов/путей/англ. + var friendly = foodmarket.Api.Infrastructure.Validation.ModelErrorTranslator.Humanize(ctx.ModelState); + var detailMsgs = friendly.Values.SelectMany(v => v).Distinct().ToList(); + var pd = new Microsoft.AspNetCore.Mvc.ValidationProblemDetails(friendly) { Status = StatusCodes.Status400BadRequest, - Title = "Сервер не принял данные", - Detail = string.Join("; ", problems.Take(3)) - + (problems.Count > 3 ? $" и ещё {problems.Count - 3}" : ""), + Title = "Проверьте правильность заполнения", + Detail = string.Join("; ", detailMsgs.Take(3)) + + (detailMsgs.Count > 3 ? $" и ещё {detailMsgs.Count - 3}" : ""), }; return new Microsoft.AspNetCore.Mvc.BadRequestObjectResult(pd) { diff --git a/tests/food-market.IntegrationTests/MobileApiTests.cs b/tests/food-market.IntegrationTests/MobileApiTests.cs index 270dba1..a5dcde6 100644 --- a/tests/food-market.IntegrationTests/MobileApiTests.cs +++ b/tests/food-market.IntegrationTests/MobileApiTests.cs @@ -139,6 +139,36 @@ public async Task Stock_group_filter_with_includeZero_shows_group_products_witho } } + // ── 1d. Ошибки ввода — дружелюбный русский без имён классов/путей ────── + [Fact] + public async Task Invalid_enum_value_returns_friendly_russian_without_class_names() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"mob-err-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var pid = await api.CreateProductAsync(refs, $"Списываемый-{Guid.NewGuid():N}", 100m, RandomBarcode()); + + // Списание с несуществующим значением enum «reason» → ранее приходил + // технический англ. текст с именем класса LossInput. Теперь — русский. + var json = System.Text.Json.JsonSerializer.Serialize(new + { + date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId, + reason = "НЕвернаяПричина", notes = "x", + lines = new[] { new { productId = pid, quantity = 1m, unitCost = 10m } }, + }); + using var content = new StringContent(json, System.Text.Encoding.UTF8, "application/json"); + using var resp = await api.Http.PostAsync("/api/inventory/losses", content); + resp.StatusCode.Should().Be(HttpStatusCode.BadRequest); + var body = await resp.Content.ReadAsStringAsync(); + + body.Should().NotContain("foodmarket.", "в ответе не должно быть имён классов"); + body.Should().NotContain("JSON value", "технический англ. текст недопустим"); + body.Should().NotContain("could not be converted"); + body.Should().NotContain("$.", "пути JSON не показываем пользователю"); + body.Should().Contain("Причина", "поле названо по-русски"); + body.Should().MatchRegex("[Нн]еверное значение", "дружелюбная формулировка"); + } + // ── 2. Добавление штрихкода к товару ─────────────────────────────────── [Fact] public async Task Add_barcode_creates_and_rejects_duplicate()