food-market/tests/food-market.IntegrationTests/MobileApiTests.cs
nns 4aceda3e41 fix(errors): дружелюбные русские ошибки ввода вместо технических JSON-текстов
Неверный тип/enum в теле запроса давал пользователю технический англ. текст с
именем класса и путём ($.reason: The JSON value could not be converted to
foodmarket.Api…LossInput…). Теперь InvalidModelStateResponseFactory прогоняет
ModelState через ModelErrorTranslator: технические сообщения → «Неверное значение
поля «Причина»» (имя поля по-русски, без классов/путей/англ.), осмысленные
FluentValidation-сообщения остаются как есть. Сырой текст пишется только в лог
бэкенда. Действует на web (toast) и mobile API. Тест: списание с неверным enum.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-09 09:52:33 +05:00

242 lines
15 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>Доработки сервера для iOS «FM Склад»: фильтр остатков по группе
/// (вложенной), добавление штрихкода, права в /api/me, весовые этикетки.</summary>
[Collection(ApiCollection.Name)]
public class MobileApiTests
{
private readonly ApiFactory _factory;
public MobileApiTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
private static async Task<string> CreateGroupAsync(ApiActor api, string name, string? parentId)
{
var resp = await api.Http.PostAsJsonAsync("/api/catalog/product-groups", new { name, parentId });
var body = await resp.Content.ReadAsStringAsync();
resp.IsSuccessStatusCode.Should().BeTrue($"create group: {(int)resp.StatusCode} {body}");
return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!;
}
private static async Task<string> CreateProductInGroupAsync(
ApiActor api, ApiActor.Refs refs, string name, decimal price, string barcode, string groupId)
{
var resp = await api.Http.PostAsJsonAsync("/api/catalog/products", new
{
name, article = (string?)null, unitOfMeasureId = refs.UnitId, vat = 12, vatEnabled = true,
productGroupId = groupId, isService = false, packaging = 0, isMarked = false,
referencePrice = price * 0.7m, purchaseCurrencyId = refs.CurrencyId,
prices = new[] { new { priceTypeId = refs.PriceTypeId, amount = price, currencyId = refs.CurrencyId } },
barcodes = new[] { new { code = barcode, type = 0, isPrimary = true } },
});
var body = await resp.Content.ReadAsStringAsync();
resp.IsSuccessStatusCode.Should().BeTrue($"create product: {(int)resp.StatusCode} {body}");
return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!;
}
private static async Task SeedStockAsync(ApiActor api, ApiActor.Refs refs, string productId, decimal qty)
{
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "seed", lines = new[] { new { productId, quantity = qty, unitCost = 50m } },
});
enter.EnsureSuccessStatusCode();
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
}
// ── 1. Фильтр остатков по группе (с вложенными) ────────────────────────
[Fact]
public async Task Stock_filter_by_group_includes_nested_groups()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"mob-grp-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var parent = await CreateGroupAsync(api, $"Напитки-{Guid.NewGuid():N}", null);
var child = await CreateGroupAsync(api, $"Соки-{Guid.NewGuid():N}", parent);
var pInParent = await CreateProductInGroupAsync(api, refs, $"Вода-{Guid.NewGuid():N}", 100m, RandomBarcode(), parent);
var pInChild = await CreateProductInGroupAsync(api, refs, $"Сок-{Guid.NewGuid():N}", 200m, RandomBarcode(), child);
await SeedStockAsync(api, refs, pInParent, 5m);
await SeedStockAsync(api, refs, pInChild, 7m);
// Фильтр по родителю → оба товара (вложенность).
var byParent = await api.Http.GetFromJsonAsync<JsonElement>($"/api/inventory/stock?groupId={parent}&pageSize=100");
var parentIds = byParent.GetProperty("items").EnumerateArray()
.Select(x => x.GetProperty("productId").GetString()).ToHashSet();
parentIds.Should().Contain(pInParent);
parentIds.Should().Contain(pInChild, "вложенная группа входит в фильтр родителя");
// Фильтр по дочерней → только её товар.
var byChild = await api.Http.GetFromJsonAsync<JsonElement>($"/api/inventory/stock?groupId={child}&pageSize=100");
var childIds = byChild.GetProperty("items").EnumerateArray()
.Select(x => x.GetProperty("productId").GetString()).ToHashSet();
childIds.Should().Contain(pInChild);
childIds.Should().NotContain(pInParent);
}
// ── 1b. «Показать нулевые»: товары без прихода тоже видны ──────────────
[Fact]
public async Task Stock_includeZero_shows_products_never_received()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"mob-zero-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var withStock = await api.CreateProductAsync(refs, $"Приход-{Guid.NewGuid():N}", 100m, RandomBarcode());
var neverReceived = await api.CreateProductAsync(refs, $"Нольный-{Guid.NewGuid():N}", 150m, RandomBarcode());
await SeedStockAsync(api, refs, withStock, 5m);
// Без includeZero — только товар с остатком.
var def = await api.Http.GetFromJsonAsync<JsonElement>("/api/inventory/stock?pageSize=500");
var defIds = def.GetProperty("items").EnumerateArray()
.Select(x => x.GetProperty("productId").GetString()).ToHashSet();
defIds.Should().Contain(withStock);
defIds.Should().NotContain(neverReceived, "без includeZero товар без прихода не показывается");
// С includeZero=true — виден и товар без прихода, с quantity 0 и штрихкодом.
var all = await api.Http.GetFromJsonAsync<JsonElement>("/api/inventory/stock?includeZero=true&pageSize=500");
var zeroRow = all.GetProperty("items").EnumerateArray()
.FirstOrDefault(x => x.GetProperty("productId").GetString() == neverReceived);
zeroRow.ValueKind.Should().NotBe(JsonValueKind.Undefined, "товар без прихода виден при includeZero");
zeroRow.GetProperty("quantity").GetDecimal().Should().Be(0m);
zeroRow.GetProperty("barcode").GetString().Should().NotBeNullOrEmpty("штрихкод отдаётся для различения дублей");
}
// ── 1c. Фильтр по группе + нулевые (корневая причина iOS-бага) ─────────
[Fact]
public async Task Stock_group_filter_with_includeZero_shows_group_products_without_stock()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"mob-gz-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var grp = await CreateGroupAsync(api, $"Группа-{Guid.NewGuid():N}", null);
var p = await CreateProductInGroupAsync(api, refs, $"БезПрихода-{Guid.NewGuid():N}", 100m, RandomBarcode(), grp);
// Остаток НЕ заводим.
// Без includeZero — группа «пустая».
var def = await api.Http.GetFromJsonAsync<JsonElement>($"/api/inventory/stock?groupId={grp}&pageSize=100");
def.GetProperty("total").GetInt32().Should().Be(0);
// С includeZero — товар группы виден с нулём. И productGroupId — синоним groupId.
foreach (var param in new[] { $"groupId={grp}", $"productGroupId={grp}" })
{
var all = await api.Http.GetFromJsonAsync<JsonElement>($"/api/inventory/stock?{param}&includeZero=true&pageSize=100");
var ids = all.GetProperty("items").EnumerateArray()
.Select(x => x.GetProperty("productId").GetString()).ToHashSet();
ids.Should().Contain(p, $"параметр {param} должен фильтровать по группе и показывать нулевые");
}
}
// ── 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()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"mob-bc-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"A-{Guid.NewGuid():N}", 100m, RandomBarcode());
var p2 = await api.CreateProductAsync(refs, $"B-{Guid.NewGuid():N}", 100m, RandomBarcode());
var newCode = RandomBarcode();
// Добавляем новый код к p1.
using var add = await api.Http.PostAsJsonAsync($"/api/catalog/products/{p1}/barcodes", new { code = newCode, kind = "ean13" });
add.StatusCode.Should().Be(HttpStatusCode.Created);
var created = await add.Content.ReadFromJsonAsync<JsonElement>();
created.GetProperty("code").GetString().Should().Be(newCode);
created.GetProperty("type").GetString().Should().Be("Ean13");
// Повтор того же кода тому же товару → идемпотентно 200.
using var again = await api.Http.PostAsJsonAsync($"/api/catalog/products/{p1}/barcodes", new { code = newCode, kind = (string?)null });
again.StatusCode.Should().Be(HttpStatusCode.OK);
// Тот же код другому товару → 409 с названием владельца.
using var dup = await api.Http.PostAsJsonAsync($"/api/catalog/products/{p2}/barcodes", new { code = newCode, kind = (string?)null });
dup.StatusCode.Should().Be(HttpStatusCode.Conflict);
var err = await dup.Content.ReadFromJsonAsync<JsonElement>();
err.GetProperty("code").GetString().Should().Be("barcode-duplicate");
err.GetProperty("error").GetString().Should().Contain("уже у товара");
// Пустой код → 400.
using var bad = await api.Http.PostAsJsonAsync($"/api/catalog/products/{p1}/barcodes", new { code = "", kind = (string?)null });
bad.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
// ── 3. Права текущего пользователя в /api/me ───────────────────────────
[Fact]
public async Task Me_returns_effective_permissions()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"mob-me-{Guid.NewGuid():N}");
var me = await api.Http.GetFromJsonAsync<JsonElement>("/api/me");
me.GetProperty("roles").EnumerateArray().Select(r => r.GetString()).Should().Contain("Admin");
var perms = me.GetProperty("permissions");
perms.GetProperty("ProductsEdit").GetBoolean().Should().BeTrue();
perms.GetProperty("StocksView").GetBoolean().Should().BeTrue();
// Новая орг по умолчанию в простом режиме → advanced-only скрыты даже у Admin.
me.GetProperty("advancedMode").GetBoolean().Should().BeFalse();
perms.GetProperty("DemandsView").GetBoolean().Should().BeFalse();
perms.GetProperty("TransferEdit").GetBoolean().Should().BeFalse();
}
// ── 5. Весовые этикетки в поиске по штрихкоду ──────────────────────────
[Fact]
public async Task ByBarcode_parses_weighted_label_returns_product_and_quantity()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"mob-w-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
// PLU = 12345 хранится как короткий штрихкод товара.
var pid = await api.CreateProductAsync(refs, $"Яблоки-{Guid.NewGuid():N}", 690m, "12345");
// Весовая этикетка: 21 | 12345 | 01500 | K → вес 1.500 кг.
var resp = await api.Http.GetFromJsonAsync<JsonElement>("/api/catalog/products/by-barcode/2112345015007");
resp.GetProperty("weighted").GetBoolean().Should().BeTrue();
resp.GetProperty("valueKind").GetString().Should().Be("WeightKg");
resp.GetProperty("quantity").GetDecimal().Should().Be(1.500m);
resp.GetProperty("product").GetProperty("id").GetString().Should().Be(pid);
}
}