using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
/// Доработки сервера для iOS «FM Склад»: фильтр остатков по группе
/// (вложенной), добавление штрихкода, права в /api/me, весовые этикетки.
[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 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 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()).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($"/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($"/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("/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("/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($"/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($"/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();
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();
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("/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("/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("/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);
}
}