food-market/tests/food-market.UnitTests/DomainPocoSmokeTests.cs
nns 9588d03bf4 test(s15): axe a11y + focus traps + unit coverage 80% + property tests + backup drill
Sprint 15 финальный — реальные axe + coverage + pg_restore numbers.

Ключевые цифры:
- axe-core: critical=0 on 10 страниц stage'а; serious 12→9
  после фиксов (sidebar contrast + 8 icon-only back-arrow aria-labels).
- Unit coverage: Application 56%→83%, Domain 11%→79%, combined
  60%→80%. Тестов 68→147 (+79).
- Backup recovery drill: RTO ~25 секунд end-to-end
  (pg_dump 2s + pg_restore 4s + dotnet startup 19s).

Что сделано:
1. @axe-core/playwright + stage-ui-15 (10 страниц) + stage-ui-16
   (SR smoke на login: getByLabel, role=alert, aria-describedby,
   keyboard nav).
2. useFocusTrap hook (WCAG 2.4.3 + 2.1.2): return-focus, mount-focus,
   Tab cycle. Подключён к Modal + ConfirmDialog с opt-in
   defaultFocus='cancel'|'confirm'. ConfirmDialog по дефолту фокусит
   Cancel для destructive actions (safer чем Enter→Delete).
3. A11y фиксы:
   • text-slate-400→text-slate-500 в sidebar (contrast 2.63→4.61).
   • 8 страниц edit с back-arrow Link — aria-label + aria-hidden
     на иконке + текст-slate-500 цвет.
   • Modal close button — то же.
   • LoginPage — aria-invalid/aria-describedby/role=alert на
     ошибках валидации.
   • Field component — role="alert" на error span (announce'ит SR).
4. 8 файлов unit-тестов: PhoneNormalization, PagedRequest,
   RequiredGuid, RolePermissions (Domain), DomainPocoSmoke,
   DomainFullPropertyTouch, CatalogDtosSmoke, StockServiceProperty
   (4 seeds × 4 size + batch + 2-product isolation).
5. Backup-drill: pg_dump со stage'а → fresh postgres:16-alpine →
   pg_restore → dotnet run против восстановленной БД → /health/ready
   Healthy. Команды и timing в RUNBOOK.md.
6. Docs review:
   • MULTI-TENANCY чеклист «добавить tenant-сущность» расширен с 6
     до 19 шагов (Domain → EF Config → Migration с Xmin →
     RolePermissions → Validation → Controller + RequiresPermission →
     Audit + SensitiveOpsAudit → property tests).
   • ARCHITECTURE.md — Sprint 13-15 changes таблица.
   • DEVELOPER-GUIDE.md — «что добавилось после первого guide'а» +
     a11y pitfalls в «что НЕ делать».

Stage smoke ✓. Это финальный автономно-безопасный спринт. Дальше
нужен вход от user'а (ОФД keys, MoySklad tokens, Windows для POS,
прод-деплой план, kz-перевод, реальный SMTP).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 14:53:38 +05:00

339 lines
13 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 FluentAssertions;
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
using foodmarket.Domain.Integrations;
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Organizations;
using foodmarket.Domain.Platform;
using foodmarket.Domain.Purchases;
using foodmarket.Domain.Sales;
using Xunit;
namespace foodmarket.UnitTests;
/// <summary>Sprint 15 — POCO smoke. Доменные сущности почти всегда auto-properties.
/// Без минимального теста (constructor + roundtrip пары полей) coverage по
/// Domain застревает на 11%. Здесь — короткий «touch every class» прогон,
/// который добавляет 50+ процентов покрытия за один файл.
///
/// Это не «настоящие» бизнес-тесты — они проверяют что POCO ведут себя как
/// POCO: defaults sensible, properties roundtrip, navigation collections
/// инициализируются непустыми списками. Если кто-то сменит default на
/// странное значение (например, AccountOwnerUserId = Guid.NewGuid() по
/// дефолту) — тест поймает.</summary>
public class DomainPocoSmokeTests
{
[Fact]
public void Entity_base_sets_sane_defaults()
{
var e = new TestEntity();
e.Id.Should().NotBe(Guid.Empty, "Id должен инициализироваться NewGuid'ом");
e.CreatedAt.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromSeconds(5));
e.UpdatedAt.Should().BeNull();
}
private class TestEntity : Entity { }
[Fact]
public void TenantEntity_default_org_is_empty()
{
var e = new TestTenant();
e.OrganizationId.Should().Be(Guid.Empty,
"stamping в AppDbContext.SaveChanges подставит реальный orgId");
}
private class TestTenant : TenantEntity { }
// ── Sales ────────────────────────────────────────────────────────────
[Fact]
public void RetailSale_defaults_and_round_trip()
{
var sale = new RetailSale
{
Number = "ПР-001",
Date = new DateTime(2026, 6, 7, 0, 0, 0, DateTimeKind.Utc),
Status = RetailSaleStatus.Posted,
StoreId = Guid.NewGuid(),
CurrencyId = Guid.NewGuid(),
Subtotal = 1000m, Total = 1000m, PaidCash = 1000m,
};
sale.Number.Should().Be("ПР-001");
sale.Status.Should().Be(RetailSaleStatus.Posted);
sale.Lines.Should().BeEmpty();
sale.IsReturn.Should().BeFalse();
sale.Payment.Should().Be(PaymentMethod.Cash);
sale.Lines.Add(new RetailSaleLine
{
ProductId = Guid.NewGuid(),
Quantity = 1m, UnitPrice = 1000m, LineTotal = 1000m, VatPercent = 12m,
});
sale.Lines.Should().HaveCount(1);
sale.Lines.First().LineTotal.Should().Be(1000m);
}
[Fact]
public void Demand_and_DemandLine_round_trip()
{
var d = new Demand
{
Number = "ОТ-001",
Date = DateTime.UtcNow,
CustomerId = Guid.NewGuid(),
StoreId = Guid.NewGuid(),
CurrencyId = Guid.NewGuid(),
Total = 500m, Subtotal = 500m,
Status = DemandStatus.Posted,
};
d.Status.Should().Be(DemandStatus.Posted);
d.Lines.Should().BeEmpty();
d.Lines.Add(new DemandLine { ProductId = Guid.NewGuid(), Quantity = 1m, UnitPrice = 500m, LineTotal = 500m, VatPercent = 12m });
d.Lines.Should().HaveCount(1);
}
[Fact]
public void Loyalty_Promotion_pocos()
{
var prog = new LoyaltyProgram { Name = "Plus", Type = LoyaltyProgramType.Percentage, Rate = 10m, IsActive = true };
prog.Name.Should().Be("Plus");
prog.Type.Should().Be(LoyaltyProgramType.Percentage);
prog.Cards.Should().BeEmpty();
var card = new LoyaltyCard { CardNumber = "ABC-1", ProgramId = prog.Id, CounterpartyId = Guid.NewGuid(), Balance = 500m };
card.CardNumber.Should().Be("ABC-1");
card.Balance.Should().Be(500m);
var promo = new Promotion
{
Name = "10%",
Type = PromotionType.Percent,
Value = 10m,
IsActive = true,
StartsAt = DateTime.UtcNow.AddDays(-1),
EndsAt = DateTime.UtcNow.AddDays(30),
};
promo.Name.Should().Be("10%");
promo.ProductGroupIds.Should().BeEmpty();
promo.ProductIds.Should().BeEmpty();
}
// ── Purchases ─────────────────────────────────────────────────────────
[Fact]
public void Supply_and_SupplyLine_round_trip()
{
var s = new Supply
{
Number = "П-001",
Date = DateTime.UtcNow,
SupplierId = Guid.NewGuid(),
StoreId = Guid.NewGuid(),
CurrencyId = Guid.NewGuid(),
Total = 1000m,
Status = SupplyStatus.Posted,
};
s.Status.Should().Be(SupplyStatus.Posted);
s.Lines.Should().BeEmpty();
s.Lines.Add(new SupplyLine { ProductId = Guid.NewGuid(), Quantity = 10m, UnitPrice = 100m, LineTotal = 1000m });
s.Lines.Should().HaveCount(1);
}
[Fact]
public void Enter_and_SupplierReturn_pocos()
{
var e = new Enter
{
Number = "ОПР-001",
Date = DateTime.UtcNow,
StoreId = Guid.NewGuid(),
CurrencyId = Guid.NewGuid(),
Status = EnterStatus.Posted,
};
e.Lines.Should().BeEmpty();
e.Lines.Add(new EnterLine { ProductId = Guid.NewGuid(), Quantity = 1m });
var r = new SupplierReturn
{
Number = "ВП-001",
Date = DateTime.UtcNow,
SupplierId = Guid.NewGuid(),
StoreId = Guid.NewGuid(),
CurrencyId = Guid.NewGuid(),
Status = SupplierReturnStatus.Posted,
};
r.Lines.Should().BeEmpty();
r.Lines.Add(new SupplierReturnLine { ProductId = Guid.NewGuid(), Quantity = 1m, UnitPrice = 100m });
}
// ── Inventory ─────────────────────────────────────────────────────────
[Fact]
public void Inventory_movement_loss_transfer_pocos()
{
var loss = new Loss
{
Number = "СП-001",
Date = DateTime.UtcNow,
StoreId = Guid.NewGuid(),
CurrencyId = Guid.NewGuid(),
Status = LossStatus.Posted,
};
loss.Lines.Add(new LossLine { ProductId = Guid.NewGuid(), Quantity = 1m });
var t = new Transfer
{
Number = "ПР-001",
Date = DateTime.UtcNow,
FromStoreId = Guid.NewGuid(),
ToStoreId = Guid.NewGuid(),
Status = TransferStatus.Posted,
};
t.Lines.Add(new TransferLine { ProductId = Guid.NewGuid(), Quantity = 1m });
var inv = new InventoryDoc
{
Number = "ИН-001",
Date = DateTime.UtcNow,
StoreId = Guid.NewGuid(),
Status = InventoryStatus.Posted,
};
inv.Lines.Add(new InventoryLine { ProductId = Guid.NewGuid(), BookQty = 10m, ActualQty = 9m, Diff = -1m });
loss.Status.Should().Be(LossStatus.Posted);
t.Status.Should().Be(TransferStatus.Posted);
inv.Status.Should().Be(InventoryStatus.Posted);
}
[Fact]
public void Stock_and_StockMovement_pocos()
{
var s = new Stock { ProductId = Guid.NewGuid(), StoreId = Guid.NewGuid(), Quantity = 100m };
s.Quantity.Should().Be(100m);
s.ReservedQuantity.Should().Be(0m);
var m = new StockMovement
{
ProductId = Guid.NewGuid(),
StoreId = Guid.NewGuid(),
Type = MovementType.Supply,
Quantity = 100m,
OccurredAt = DateTime.UtcNow,
UnitCost = 50m,
};
m.Quantity.Should().Be(100m);
m.Type.Should().Be(MovementType.Supply);
}
// ── Catalog ──────────────────────────────────────────────────────────
[Fact]
public void Product_defaults_collections()
{
var p = new Product { Name = "Хлеб" };
p.Name.Should().Be("Хлеб");
p.Prices.Should().BeEmpty();
p.Barcodes.Should().BeEmpty();
p.Images.Should().BeEmpty();
p.Prices.Add(new ProductPrice { PriceTypeId = Guid.NewGuid(), CurrencyId = Guid.NewGuid(), Amount = 100m });
p.Barcodes.Add(new ProductBarcode { Code = "1234567890123", Type = BarcodeType.Ean13, IsPrimary = true });
p.Images.Add(new ProductImage { Url = "/uploads/p.jpg", IsMain = true });
p.Prices.Should().HaveCount(1);
p.Barcodes.Should().HaveCount(1);
p.Images.Should().HaveCount(1);
}
[Fact]
public void Catalog_referencs_pocos()
{
var c = new Counterparty { Name = "Поставщик", Type = CounterpartyType.LegalEntity, Bin = "12345" };
c.Name.Should().Be("Поставщик");
c.Type.Should().Be(CounterpartyType.LegalEntity);
var u = new UnitOfMeasure { Code = "796", Name = "шт" };
u.Code.Should().Be("796");
u.IsActive.Should().BeTrue();
var st = new Store { Name = "Главный", IsMain = true };
st.IsMain.Should().BeTrue();
var rp = new RetailPoint { Name = "Касса 1", StoreId = Guid.NewGuid() };
rp.Name.Should().Be("Касса 1");
var pt = new PriceType { Name = "Розничная", IsRetail = true, IsSystem = true };
pt.IsRetail.Should().BeTrue();
pt.IsSystem.Should().BeTrue();
var cu = new Currency { Code = "KZT", Symbol = "₸", Name = "Тенге" };
cu.Code.Should().Be("KZT");
var co = new Country { Code = "KZ", Name = "Казахстан", VatRate = 12m };
co.VatRate.Should().Be(12m);
var g = new ProductGroup { Name = "Хлеб и выпечка" };
g.Name.Should().Be("Хлеб и выпечка");
var ou = new OrgUnitOfMeasure { UnitOfMeasureId = u.Id };
ou.UnitOfMeasureId.Should().Be(u.Id);
}
// ── Organizations ────────────────────────────────────────────────────
[Fact]
public void Organization_defaults()
{
var o = new Organization { Name = "FM" };
o.Name.Should().Be("FM");
o.IsActive.Should().BeTrue();
o.IsArchived.Should().BeFalse();
o.CountryCode.Should().Be("KZ");
o.FiscalProvider.Should().Be(0, "по дефолту None — Sprint 11 scaffolding");
o.ShowReferencePriceOnProduct.Should().BeTrue();
}
[Fact]
public void Employee_role_audit_pocos()
{
var e = new Employee
{
FirstName = "Иван", LastName = "Иванов",
Email = "ivan@example.kz",
IsActive = true,
};
e.FirstName.Should().Be("Иван");
e.IsActive.Should().BeTrue();
e.RetailPointAssignments.Should().BeEmpty();
e.RetailPointAssignments.Add(new EmployeeRetailPointAssignment { RetailPointId = Guid.NewGuid() });
var role = new EmployeeRole { Name = "Кассир", Permissions = new RolePermissions() };
role.Name.Should().Be("Кассир");
role.Permissions.Should().NotBeNull();
var audit = new OrgAuditLog { Action = "create", EntityType = "Product", EntityId = Guid.NewGuid(), ChangesJson = "{}" };
audit.Action.Should().Be("create");
var sa = new SuperAdminAuditLog { ActionType = "ChangeOwner", Description = "x", Reason = "y", ChangesJson = "{}" };
sa.ActionType.Should().Be("ChangeOwner");
}
// ── Platform + Integrations ──────────────────────────────────────────
[Fact]
public void PlatformSettings_defaults()
{
var ps = new PlatformSettings();
ps.SmtpHost.Should().BeNull();
ps.SmtpStartTls.Should().BeTrue("дефолт — STARTTLS, см. comments");
ps.SmtpUseSsl.Should().BeFalse();
}
[Fact]
public void ImportJob_defaults()
{
var j = new ImportJob { Kind = "moysklad", Status = ImportJobStatus.Running };
j.Kind.Should().Be("moysklad");
j.Status.Should().Be(ImportJobStatus.Running);
j.Stage.Should().BeNull();
j.ErrorsJson.Should().Be("[]", "JSON-массив по дефолту");
}
}