test(mobile-api): unit + integration доработок для «FM Склад»
- unit: WeightedBarcode (префиксы/код/вес/цена/настройки) + RolePermissions.Effective (SuperAdmin/Admin/роль × простой/расширенный) — 10 тестов. - integration (Testcontainers): фильтр остатков по вложенной группе; добавление штрихкода (201/идемпотентно/409 дубль/400); /api/me permissions; весовая этикетка в by-barcode (товар + количество) — 4 теста. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
2a4ba14d38
commit
14001069a2
157
tests/food-market.IntegrationTests/MobileApiTests.cs
Normal file
157
tests/food-market.IntegrationTests/MobileApiTests.cs
Normal file
|
|
@ -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;
|
||||||
|
|
||||||
|
/// <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);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
120
tests/food-market.UnitTests/WeightedBarcodeTests.cs
Normal file
120
tests/food-market.UnitTests/WeightedBarcodeTests.cs
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue