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_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("/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); } }