food-market/tests/food-market.IntegrationTests/MobileApiTests.cs
nns d0fee10e96 fix(barcode): весовой разбор выкл по умолчанию; поиск по ШК — точное совпадение
Реальный сценарий владельца: весов с печатью этикеток нет, штрихкоды весовых —
обычные коды «27xxxxx» в карточке. Исправлены два бага:
- поиск по ШК матчил весовой PLU по АРТИКУЛУ (вода арт.12345 → 1,5 кг) — убрано,
  поиск по штрихкоду больше нигде не матчит по артикулу;
- разбор весовых этикеток был дефолт-ON (WeightedBarcodeSettings.Enabled,
  Organization.WeightBarcodeEnabled) и ловил обычные коды — теперь дефолт OFF.

- ByBarcode: сначала точное совпадение штрихкода (вкл. короткие «27xxxxx»),
  разбор этикетки — только если включён в орг И точного ШК нет.
- Phase40: WeightBarcodeEnabled DEFAULT false + выключение у существующих орг;
  WeightedCodeFormat('short'|'ean13')/WeightedCodePrefix(27)/WeightedCodeDigits(5).
- Генератор ШК нового весового товара: короткий «<prefix>+N цифр» (27xxxxx, next-free)
  или EAN-13 по настройке; уникальность.
- Настройки орг отдают/принимают формат кода весовых.
- Sync отдаёт unitShort («шт»/«кг»/«л») рядом с unitCode (ОКЕИ) — shared UnitShort.
- Тесты: MobileApi (exact-only по умолчанию; не матчит артикул; PLU как ШК при вкл) 9/9;
  unit WeightedBarcode/UnitShort 16/16.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 11:02:43 +05:00

320 lines
20 KiB
C#
Raw Permalink 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_default_exact_only_no_weighted_no_article()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"mob-w-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
// Короткий штрихкод «27xxxxx» в карточке (как у владельца).
var pid = await api.CreateProductAsync(refs, $"Яблоки-{Guid.NewGuid():N}", 690m, "2712345");
// Точное совпадение короткого кода → товар, НЕ весовой.
var exact = await api.Http.GetFromJsonAsync<JsonElement>("/api/catalog/products/by-barcode/2712345");
exact.GetProperty("id").GetString().Should().Be(pid);
exact.TryGetProperty("weighted", out _).Should().BeFalse("по умолчанию весовой разбор выключен");
// Весоподобная «этикетка» 2112345015007 по умолчанию НЕ разбирается и точного
// штрихкода нет → 404. (Раньше матчилось по PLU/артикулу — баг владельца.)
using var label = await api.Http.GetAsync("/api/catalog/products/by-barcode/2112345015007");
label.StatusCode.Should().Be(HttpStatusCode.NotFound);
}
// ── 5b. Поиск по ШК НЕ матчит по артикулу (баг «вода 12345 → 1,5 кг») ──
[Fact]
public async Task ByBarcode_never_matches_by_article_even_when_weighted_enabled()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"mob-art-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
// Товар с АРТИКУЛОМ 12345 и другим штрихкодом (как «вода» у владельца).
var water = await api.Http.PostAsJsonAsync("/api/catalog/products", new
{
name = $"Вода-{Guid.NewGuid():N}", article = "12345", unitOfMeasureId = refs.UnitId,
vat = 12, vatEnabled = true, productGroupId = refs.GroupId, isService = false,
packaging = 0, isMarked = false, referencePrice = 70m, purchaseCurrencyId = refs.CurrencyId,
prices = new[] { new { priceTypeId = refs.PriceTypeId, amount = 100m, currencyId = refs.CurrencyId } },
barcodes = new[] { new { code = RandomBarcode(), type = 0, isPrimary = true } },
});
water.EnsureSuccessStatusCode();
// Включаем весовой разбор в организации (эхо текущих настроек + флаг).
var cur = await api.GetJsonAsync("/api/organization/settings");
string S(string k) => cur.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString()! : "";
bool B(string k) => cur.TryGetProperty(k, out var v) && (v.ValueKind == JsonValueKind.True);
using var put = await api.Http.PutAsJsonAsync("/api/organization/settings", new
{
name = S("name"), countryCode = S("countryCode"), multiCurrencyEnabled = B("multiCurrencyEnabled"),
showVatEnabledOnProduct = B("showVatEnabledOnProduct"), showServiceOnProduct = B("showServiceOnProduct"),
showMarkedOnProduct = B("showMarkedOnProduct"), showMinMaxStock = B("showMinMaxStock"),
allowFractionalPrices = B("allowFractionalPrices"), allowNegativeBalance = B("allowNegativeBalance"),
showReferencePriceOnProduct = B("showReferencePriceOnProduct"),
showCountryOfOriginOnProduct = B("showCountryOfOriginOnProduct"),
showDescriptionOnProduct = B("showDescriptionOnProduct"),
weightBarcodeEnabled = true, // включаем разбор
});
put.EnsureSuccessStatusCode();
// Весовая этикетка с PLU 12345: артикула у воды 12345, но по ШК «12345» товара
// нет → НЕ должно найтись по артикулу → 404 (а не «вода 1,5 кг»).
using var label = await api.Http.GetAsync("/api/catalog/products/by-barcode/2112345015007");
label.StatusCode.Should().Be(HttpStatusCode.NotFound,
"поиск по штрихкоду не матчит по артикулу");
}
// ── 5c. С включённым разбором PLU ищется как ШТРИХКОД ──────────────────
[Fact]
public async Task ByBarcode_weighted_enabled_matches_plu_as_barcode()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"mob-wen-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
// PLU 12345 как ШТРИХКОД карточки.
var pid = await api.CreateProductAsync(refs, $"Яблоки-{Guid.NewGuid():N}", 690m, "12345");
var cur = await api.GetJsonAsync("/api/organization/settings");
string S(string k) => cur.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.String ? v.GetString()! : "";
bool B(string k) => cur.TryGetProperty(k, out var v) && v.ValueKind == JsonValueKind.True;
(await api.Http.PutAsJsonAsync("/api/organization/settings", new
{
name = S("name"), countryCode = S("countryCode"), multiCurrencyEnabled = B("multiCurrencyEnabled"),
showVatEnabledOnProduct = B("showVatEnabledOnProduct"), showServiceOnProduct = B("showServiceOnProduct"),
showMarkedOnProduct = B("showMarkedOnProduct"), showMinMaxStock = B("showMinMaxStock"),
allowFractionalPrices = B("allowFractionalPrices"), allowNegativeBalance = B("allowNegativeBalance"),
showReferencePriceOnProduct = B("showReferencePriceOnProduct"),
showCountryOfOriginOnProduct = B("showCountryOfOriginOnProduct"),
showDescriptionOnProduct = B("showDescriptionOnProduct"), weightBarcodeEnabled = true,
})).EnsureSuccessStatusCode();
// 21 | 12345 | 01500 → вес 1.500 кг, товар найден по штрихкоду 12345.
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);
}
}