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>
339 lines
13 KiB
C#
339 lines
13 KiB
C#
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-массив по дефолту");
|
||
}
|
||
}
|