diff --git a/tests/food-market.IntegrationTests/MobileApiTests.cs b/tests/food-market.IntegrationTests/MobileApiTests.cs new file mode 100644 index 0000000..d643055 --- /dev/null +++ b/tests/food-market.IntegrationTests/MobileApiTests.cs @@ -0,0 +1,157 @@ +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); + } + + // ── 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); + } +} diff --git a/tests/food-market.UnitTests/WeightedBarcodeTests.cs b/tests/food-market.UnitTests/WeightedBarcodeTests.cs new file mode 100644 index 0000000..2c41f3b --- /dev/null +++ b/tests/food-market.UnitTests/WeightedBarcodeTests.cs @@ -0,0 +1,120 @@ +using FluentAssertions; +using foodmarket.Domain.Organizations; +using foodmarket.Shared.Barcodes; +using Xunit; + +namespace foodmarket.UnitTests; + +public class WeightedBarcodeTests +{ + [Fact] + public void Normal_barcode_passes_through() + { + var r = WeightedBarcode.Parse("4870204010101"); + r.IsWeighted.Should().BeFalse(); + r.Lookup.Should().Be("4870204010101"); + r.WeightKg.Should().BeNull(); + r.Price.Should().BeNull(); + } + + [Fact] + public void Weight_prefix_extracts_plu_and_kg() + { + // 21 | 12345 | 01500 | K → PLU 12345, вес 1.500 кг + var r = WeightedBarcode.Parse("2112345015007"); + r.IsWeighted.Should().BeTrue(); + r.ValueKind.Should().Be(WeightedValueKind.WeightKg); + r.Lookup.Should().Be("12345"); + r.WeightKg.Should().Be(1.500m); + r.Price.Should().BeNull(); + } + + [Fact] + public void Price_prefix_extracts_plu_and_price() + { + // 25 | 54321 | 12345 | K → PLU 54321, цена 123.45 + var r = WeightedBarcode.Parse("2554321123456"); + r.IsWeighted.Should().BeTrue(); + r.ValueKind.Should().Be(WeightedValueKind.Price); + r.Lookup.Should().Be("54321"); + r.Price.Should().Be(123.45m); + r.WeightKg.Should().BeNull(); + } + + [Fact] + public void Disabled_settings_treats_weighted_as_plain() + { + var settings = WeightedBarcodeSettings.Default with { Enabled = false }; + var r = WeightedBarcode.Parse("2112345015007", settings); + r.IsWeighted.Should().BeFalse(); + r.Lookup.Should().Be("2112345015007"); + } + + [Fact] + public void Custom_prefixes_and_code_digits_honored() + { + // Префикс 28 как весовой, код 6 цифр: 28 | 123456 | 999 | K + var settings = WeightedBarcodeSettings.Create( + enabled: true, weightCsv: "28", priceCsv: "", codeDigits: 6); + var r = WeightedBarcode.Parse("2812345699990", settings); + r.IsWeighted.Should().BeTrue(); + r.ValueKind.Should().Be(WeightedValueKind.WeightKg); + r.Lookup.Should().Be("123456"); + // value digits = остаток до контрольной: позиции 8..11 = "9999" → 9.999 кг + r.WeightKg.Should().Be(9.999m); + } + + [Fact] + public void Create_from_csv_defaults_on_blank() + { + var s = WeightedBarcodeSettings.Create(true, null, "", 0); + s.WeightPrefixes.Should().Contain(new[] { 20, 21, 22, 23, 24 }); + s.PricePrefixes.Should().Contain(new[] { 25, 26, 27, 28, 29 }); + s.CodeDigits.Should().Be(5); + } +} + +public class RolePermissionsEffectiveTests +{ + [Fact] + public void SuperAdmin_gets_everything_even_in_simple_mode() + { + var p = RolePermissions.Effective(null, isSuperAdmin: true, isAdmin: false, advancedMode: false); + p["ProductsEdit"].Should().BeTrue(); + p["DemandsView"].Should().BeTrue("SuperAdmin обходит простой режим"); + p["MoneyEdit"].Should().BeTrue(); + } + + [Fact] + public void Admin_in_simple_mode_loses_advanced_only() + { + var p = RolePermissions.Effective(null, isSuperAdmin: false, isAdmin: true, advancedMode: false); + p["ProductsEdit"].Should().BeTrue("базовые права у Admin есть"); + p["DemandsView"].Should().BeFalse("advanced-only скрыт в простом режиме"); + p["TransferEdit"].Should().BeFalse(); + p["PriceTypesManage"].Should().BeFalse(); + p["StocksView"].Should().BeTrue("не advanced-only"); + } + + [Fact] + public void Admin_in_advanced_mode_keeps_advanced() + { + var p = RolePermissions.Effective(null, isSuperAdmin: false, isAdmin: true, advancedMode: true); + p["DemandsView"].Should().BeTrue(); + p["LoyaltyManage"].Should().BeTrue(); + } + + [Fact] + public void Plain_role_reflects_its_flags_and_mode() + { + var role = new RolePermissions { ProductsView = true, StocksView = true, DemandsView = true }; + var simple = RolePermissions.Effective(role, false, false, advancedMode: false); + simple["ProductsView"].Should().BeTrue(); + simple["StocksView"].Should().BeTrue(); + simple["DemandsView"].Should().BeFalse("advanced-only в простом режиме"); + simple["ProductsEdit"].Should().BeFalse("флаг не выдан роли"); + + var advanced = RolePermissions.Effective(role, false, false, advancedMode: true); + advanced["DemandsView"].Should().BeTrue(); + } +}