Неверный тип/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>
242 lines
15 KiB
C#
242 lines
15 KiB
C#
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);
|
||
}
|
||
}
|