food-market/docs/DEVELOPER-GUIDE.md
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

23 KiB
Raw Permalink Blame History

Developer guide — food-market

Как поднять проект, что куда добавлять, какие паттерны соблюдать. Предполагается, что вы прочитали ARCHITECTURE.md и понимаете слои.

Локальный setup

Что нужно

  • .NET 8 SDK 8.0.4xx (см. global.json, rollForward: latestFeature — годится любой 8.0.4xx).
  • Node 20+ и pnpm 9+ (для web).
  • PostgreSQL 14+ — на macOS обычно brew postgresql@14. БД: food_market, owner nns, пароль пустой.
  • Docker + Docker Compose — только для integration-тестов (Testcontainers) и stage-деплоя.

Поднять с нуля

git clone http://127.0.0.1:3000/nns/food-market.git
cd food-market

# 1) БД (если ещё нет)
createdb -O nns food_market   # пользователь nns должен существовать

# 2) Backend
ASPNETCORE_ENVIRONMENT=Development \
    dotnet run --project src/food-market.api
# первый запуск: применит миграции, посеит справочники, создаст
# SuperAdmin admin@food-market.local / Admin12345!.
# API на http://localhost:5081, Swagger на /swagger.

# 3) Web (в другом терминале)
cd src/food-market.web
pnpm install
pnpm dev
# http://localhost:5173

# 4) Smoke
curl http://localhost:5081/health
# и зайти в браузере, залогиниться admin@food-market.local

Получить токен из CLI

TOKEN=$(curl -sX POST http://localhost:5081/connect/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d 'grant_type=password&username=admin@food-market.local&password=Admin12345!&client_id=food-market-web&scope=openid profile email roles api offline_access' \
  | jq -r .access_token)
curl -sH "Authorization: Bearer $TOKEN" http://localhost:5081/api/me | jq

Запуск тестов

# Unit-тесты (быстрые, ~7-10с)
dotnet test tests/food-market.UnitTests/

# Integration (тянут Postgres-контейнер, ~30-60с на холодную)
dotnet test tests/food-market.IntegrationTests/

# Фильтр по имени класса/метода
dotnet test tests/... --filter "FullyQualifiedName~Fiscal"

# Web — type-check + production build
cd src/food-market.web && pnpm exec tsc --noEmit && pnpm build

# E2E (Playwright против stage)
cd tests/e2e && pnpm install
pnpm playwright test stage-smoke.spec.ts

Гочи integration-тестов

  • Testcontainers Ryuk выключен (env TESTCONTAINERS_RYUK_DISABLED=true). Причина: контейнер reaper тянет образ с Docker Hub, на dev-vm сеть туда нестабильна.
  • Rate-limiter выключен через RateLimiting__Enabled=false. Он читает конфиг ЭАГЕРНО при регистрации сервисов — поэтому только через переменную окружения (см. memory test_suites_setup).
  • Hangfire-сервер выключен (Hangfire__Enabled=false) — иначе создаёт схему и держит коннект в одноразовом контейнере.
  • Один ApiFactory на всю xUnit-сессию через [Collection(ApiCollection.Name)]. Делать второй WebApplicationFactory<Program> параллельно нельзя — HostFactoryResolver сломается.

Конвенции репо

  • C# 12, Nullable enabled, ImplicitUsings enabled.
  • Названия неймспейсов — foodmarket.Domain, foodmarket.Application, foodmarket.Infrastructure, foodmarket.Api. Папки в src/food-market.api, food-market.application, … (с дефисом). Это расхождение исторически — менять не нужно.
  • Названия таблиц в БД — snake_case (явный b.ToTable("retail_sales")), столбцы — PascalCase из C# (EF default), индексы по IX_<table>_<cols> (EF default).
  • Комментарии в коде — рассказывающие почему, не что. Если из имени переменной/метода не понятно — переименуй; если из логики не понятно, почему — комментируй.
  • XML-doc на public API в Application/Infrastructure обязателен (даёт IntelliSense для другой стороны и появляется в Swagger).
  • Локализация UI — src/lib/i18n.ts (русский по умолчанию, есть заготовка под KZ — нужен переводчик).

Паттерны: добавить controller с permission

Пример: POST /api/loyalty/programs (создание программы лояльности), доступно только Admin'у орги или SuperAdmin'у в edit-mode.

// food-market.api/Controllers/Loyalty/LoyaltyProgramsController.cs
[ApiController]
[Authorize]
[Route("api/loyalty/programs")]
public class LoyaltyProgramsController : ControllerBase
{
    private readonly AppDbContext _db;
    private readonly ITenantContext _tenant;
    private readonly ILogger<LoyaltyProgramsController> _log;

    public LoyaltyProgramsController(
        AppDbContext db, ITenantContext tenant, ILogger<LoyaltyProgramsController> log)
    {
        _db = db; _tenant = tenant; _log = log;
    }

    public record ProgramInput(
        [Required] string Name,
        [Range(1, 4)] int Type,
        [Range(0, 1000)] decimal Rate,
        bool IsActive);

    [HttpPost, RequiresPermission("LoyaltyEdit")]
    public async Task<ActionResult<Guid>> Create(
        [FromBody] ProgramInput input, CancellationToken ct)
    {
        var p = new LoyaltyProgram
        {
            Name = input.Name.Trim(),
            Type = (LoyaltyProgramType)input.Type,
            Rate = input.Rate,
            IsActive = input.IsActive,
            // OrganizationId stamping применит в SaveChanges
        };
        _db.LoyaltyPrograms.Add(p);
        await _db.SaveChangesAsync(ct);
        _log.LogInformation(
            "Loyalty program created: {ProgramId} {Name} org={OrgId}",
            p.Id, p.Name, _tenant.OrganizationId);
        return Ok(p.Id);
    }
}

Что произошло:

  • [Authorize] — JWT-токен обязателен (валидируется OpenIddict).
  • [Route("api/...")] — обычный REST-маршрут. api/ префикс обязателен для всех контроллеров (web-фронт ходит через /api/*, nginx это знает).
  • [RequiresPermission("LoyaltyEdit")] — резолвится в policy perm:LoyaltyEdit, handler проверяет RolePermissions.LoyaltyEdit булеву у роли текущего юзера. Добавь поле в RolePermissions.cs если ещё нет, миграция-AddColumn для bool LoyaltyEdit NOT NULL DEFAULT false
    • апдейт admin-роли в сидере.
  • ProgramInput — record с DataAnnotations. Для сложной валидации — отдельный FluentValidation AbstractValidator<ProgramInput> в food-market.api/Infrastructure/Validation/Validators.cs (см. паттерны там).
  • _db.LoyaltyPrograms.Add(p) без явного OrganizationIdStampTenant в SaveChangesAsync подставит.
  • Логирование структурированное: {ProgramId}, {Name}, {OrgId} — Serilog кладёт их как property'и (search'абельны в Loki/ES, не строкой).

Если нужен Admin-only (грубее)

[HttpPut, Authorize(Roles = "Admin")]

это эквивалентно «или Identity-role Admin, или SuperAdmin в режиме override (через SuperAdminOverrideClaimsTransformer)». Подходит для редких операций; для регулярных используй RequiresPermission.

Паттерны: добавить сущность с RowVersion и tenant

Допустим, нужна новая сущность PromoCode.

1. Domain

// food-market.domain/Sales/PromoCode.cs
public class PromoCode : TenantEntity, IVersionedEntity
{
    public uint Xmin { get; set; }

    public string Code { get; set; } = "";
    public decimal Discount { get; set; }
    public DateTime? ExpiresAt { get; set; }
    public bool IsActive { get; set; } = true;
}

TenantEntity даёт Id, CreatedAt, UpdatedAt, OrganizationId. IVersionedEntity + Xmin — оптимистичная блокировка через PG xmin.

2. EF Configuration

// food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs
b.Entity<PromoCode>(e =>
{
    e.ToTable("promo_codes");
    e.UseXminAsConcurrencyToken();
    e.Ignore(x => x.Xmin);
    e.Property(x => x.Code).HasMaxLength(40).IsRequired();
    e.Property(x => x.Discount).HasPrecision(18, 4);
    e.HasIndex(x => new { x.OrganizationId, x.Code }).IsUnique();  // ← OrganizationId первым!
    e.HasIndex(x => new { x.OrganizationId, x.IsActive });
});

Важно: индексы — с OrganizationId первым полем. Все запросы пройдут через query filter и будут фильтроваться по этому полю; без правильного индекса PG будет full-scan тенант-таблицы.

3. DbSet

// food-market.infrastructure/Persistence/AppDbContext.cs
public DbSet<PromoCode> PromoCodes => Set<PromoCode>();

4. Миграция руками

// food-market.infrastructure/Persistence/Migrations/20260608100000_PromoCodes.cs
[DbContext(typeof(AppDbContext))]
[Migration("20260608100000_PromoCodes")]
public partial class PromoCodes : Migration
{
    protected override void Up(MigrationBuilder b)
    {
        b.CreateTable(
            name: "promo_codes",
            schema: "public",
            columns: t => new
            {
                Id = t.Column<Guid>(type: "uuid", nullable: false),
                OrganizationId = t.Column<Guid>(type: "uuid", nullable: false),
                Code = t.Column<string>(type: "character varying(40)", maxLength: 40, nullable: false),
                Discount = t.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
                ExpiresAt = t.Column<DateTime>(type: "timestamp with time zone", nullable: true),
                IsActive = t.Column<bool>(type: "boolean", nullable: false, defaultValue: true),
                CreatedAt = t.Column<DateTime>(type: "timestamp with time zone", nullable: false),
                UpdatedAt = t.Column<DateTime>(type: "timestamp with time zone", nullable: true),
            },
            constraints: t => t.PrimaryKey("PK_promo_codes", x => x.Id));
        b.CreateIndex(
            name: "IX_promo_codes_OrganizationId_Code",
            schema: "public", table: "promo_codes",
            columns: new[] { "OrganizationId", "Code" }, unique: true);
        b.CreateIndex(
            name: "IX_promo_codes_OrganizationId_IsActive",
            schema: "public", table: "promo_codes",
            columns: new[] { "OrganizationId", "IsActive" });
    }
    protected override void Down(MigrationBuilder b)
        => b.DropTable("promo_codes", "public");
}

Обязательно: [DbContext] + [Migration("...")] атрибуты — без них db.Database.Migrate() миграцию не подхватит (memory: feedback_ef_migrations).

5. Тест на изоляцию

Добавить case в TenantIsolationTests: org A создаёт PromoCode, org B делает GET, видит пустой список.

Валидация

Простые правила — DataAnnotations

public record ProductInput(
    [Required, MaxLength(200)] string Name,
    [Range(0, 1e10)] decimal Price);

Сложные — FluentValidation

В food-market.api/Infrastructure/Validation/Validators.cs:

public sealed class ProductInputValidator : AbstractValidator<ProductInput>
{
    public ProductInputValidator()
    {
        RuleFor(x => x.Name).NotEmpty().MaximumLength(200);
        RuleFor(x => x.Price).GreaterThanOrEqualTo(0);

        // Кросс-полевые правила, async, реализующие бизнес-инвариант
        RuleFor(x => x.Lines).NotEmpty().WithMessage("Хотя бы одна позиция.");
        RuleForEach(x => x.Lines).ChildRules(line =>
        {
            line.RuleFor(l => l.Quantity).GreaterThan(0);
            line.RuleFor(l => l.UnitPrice).GreaterThanOrEqualTo(0);
        });
    }
}

Валидаторы регистрируются автоматически через AddValidatorsFromAssemblyContaining<Program>(). ValidationFilter (глобальный action-filter в Program.cs) запускает их на каждом action и возвращает 400 ProblemDetails (RFC 7807).

Бизнес-валидация (требует БД)

Если правило требует справиться с БД (например, «склад существует и не архивирован»), вынесите в первый шаг action-метода:

[HttpPost]
public async Task<ActionResult> Create(ProductInput input, CancellationToken ct)
{
    var groupOk = await _db.ProductGroups.AnyAsync(g => g.Id == input.ProductGroupId, ct);
    if (!groupOk)
        return BadRequest(new { error = "Группа не найдена.", field = "productGroupId" });
    // ...
}

Формат ошибки — { error: "...", field?: "..." }. Фронт показывает error тостом, field подсвечивает в форме.

Логирование

Используем Serilog со структурированными полями. LogEnrichmentMiddleware уже добавляет CorrelationId/OrgId/UserId в каждую запись.

Правила

  • Не строкой: _log.LogInformation("Created product " + id) — нет. _log.LogInformation("Product created: {ProductId} name={Name}", id, name) — да.
  • Уровень:
    • Trace/Debug — только для отладки конкретного бага.
    • Information — успешные mutate-операции, важные events (post/unpost документа, регистрация чека в ОФД).
    • Warning — что-то пошло не как ожидалось, но обработали (best-effort fail, retry-able ошибка).
    • Error — обработать не удалось, нужен внимательный человек.
    • Critical — приложение в плохом состоянии, может перестать работать.
  • Не логировать PII в открытом виде (пароли, токены, email — email можно, но не светить лишний раз).
  • Exception как первый аргумент: _log.LogError(ex, "...", ...), не _log.LogError("... " + ex.Message) — теряется stack trace.

Пример из RetailSalesController

_log.LogInformation(
    "RetailSale posted: {SaleNumber} store={StoreId} payment={Payment} lines={LinesCount} total={Total}",
    sale.Number, sale.StoreId, sale.Payment, sale.Lines.Count, sale.Total);

// ...

try
{
    await _notify.PublishAsync(...);
}
catch (Exception ex)
{
    // Notification — best-effort: не должна валить транзакцию (она уже закоммичена)
    _log.LogWarning(ex, "SignalR notify failed for sale {SaleId}", sale.Id);
}

SignalR realtime

Если нужно отправить уведомление на фронт (инвалидация query'я, показ тоста):

// в Program.cs INotificationsPublisher уже зарегистрирован
public class MyController : ControllerBase
{
    private readonly INotificationsPublisher _notify;

    [HttpPost("...")]
    public async Task<IActionResult> Action(...)
    {
        // ... business logic ...
        await _notify.PublishAsync(
            organizationId,
            NotificationEvents.SalePosted,    // строковая константа
            new SalePostedPayload(...));      // record DTO
        return NoContent();
    }
}

На фронте — useNotifications() хук подписан на хаб и инвалидирует relevant query'и. Новые event'ы добавлять в NotificationEvents, payload — в соседнем record'е.

Что НЕ делать

  • НЕ инжектить IServiceProvider чтобы доставать сервисы лениво — объяви явные зависимости в конструкторе. Исключение: фабрики (Fiscal, Email) которые открывают scope для свежего DbContext'а.
  • НЕ писать raw SQL (SqlQueryRaw/ExecuteSqlRaw) без явного WHERE OrganizationId = @org — query-filter не применится.
  • НЕ менять снапшот в Persistence/Migrations/AppDbContextModelSnapshot.cs руками для добавления новых полей — он используется только инструментом dotnet ef migrations add, который мы не запускаем. Trying to add partial state ломает только инструмент, ничего не дав. Если хочется — обновляй целиком, синхронно с моделью; иначе оставь как есть.
  • НЕ добавлять платные компоненты: Kendo, DevExpress, Syncfusion commercial, Telerik (CLAUDE.md).
  • НЕ менять global.json без согласования (CLAUDE.md).
  • НЕ создавать миграции через dotnet ef migrations add — пиши руками (memory: feedback_ef_migrations).
  • НЕ делать git push --force на main (Forgejo — primary).

Что добавилось после первого релиза этого guide'а

Sprint Чем пользоваться
13 SensitiveOpsAudit (food-market.api/Infrastructure/Audit/) — централизованный логгер sensitive-операций. Вместо ручного OrgAuditLogs.Add_audit.LogAsync(action, entityType, entityId, payload).
13 [RequiresPermission("X")] уже было; добавился MeSessionsController.RevokeAll — пример работы с IOpenIddictAuthorizationManager.
13 Все ответы автоматически получают security-заголовки через SecurityHeadersMiddleware. Если новый endpoint требует ослабленную CSP (например, embeds другой домен) — добавь его path в ShouldSkip middleware'a.
14 Композитный индекс (OrganizationId, …) на новых таблицах — must. Для отчётных запросов с фильтром по статусу — добавляй partial index WHERE Status = X с INCLUDE (covering).
14 ImageVariantService — при upload картинок автоматически генерирует thumb/medium WebP. Через frontend <ProductImage src={url} size="thumb" /><picture> + srcset.
14 Hangfire-jobs: JobTimingFilter глобально логирует длительность; >30с — Warning. Для своих jobs ничего настраивать не надо.
15 useFocusTrap (src/lib/useFocusTrap.ts) — focus trap + return-focus для модалок. Уже подключён в Modal и ConfirmDialog. Для своего модала: const ref = useFocusTrap<HTMLDivElement>(open).
15 a11y: каждая icon-only <button>/<a> нуждается в aria-label="..." и aria-hidden="true" на иконке внутри. Поля формы с ошибкой — aria-invalid={true} + aria-describedby="err-id" + <span id="err-id" role="alert">...</span>. Цвет текста для маленького font'аtext-slate-500 минимум (4.61 contrast), не text-slate-400 (2.63, fails WCAG AA).
15 Unit-coverage цель — 70% по строкам в Application+Domain. Добавляешь новую POCO → один touch-test в DomainPocoSmokeTests/DomainFullPropertyTouchTests. Property-based тест на бизнес-инвариант — StockServicePropertyTests-pattern (рандомные seed'ы, проверка инварианта).

Что НЕ делать

  • НЕ инжектить IServiceProvider чтобы доставать сервисы лениво — объяви явные зависимости в конструкторе. Исключение: фабрики (Fiscal, Email) которые открывают scope для свежего DbContext'а.
  • НЕ писать raw SQL (SqlQueryRaw/ExecuteSqlRaw) без явного WHERE OrganizationId = @org — query-filter не применится.
  • НЕ менять снапшот в Persistence/Migrations/AppDbContextModelSnapshot.cs руками для добавления новых полей — он используется только инструментом dotnet ef migrations add, который мы не запускаем. Trying to add partial state ломает только инструмент, ничего не дав. Если хочется — обновляй целиком, синхронно с моделью; иначе оставь как есть.
  • НЕ добавлять платные компоненты: Kendo, DevExpress, Syncfusion commercial, Telerik (CLAUDE.md).
  • НЕ менять global.json без согласования (CLAUDE.md).
  • НЕ создавать миграции через dotnet ef migrations add — пиши руками (memory: feedback_ef_migrations).
  • НЕ делать git push --force на main (Forgejo — primary).
  • НЕ использовать text-slate-400 для маленьких подписей на белом фоне — fails WCAG AA color contrast (Sprint 15). Минимум text-slate-500.
  • НЕ делать icon-only <button>/<a> без aria-label — Screen readers пропустят его (Sprint 15 axe-core finding).

Полезные ссылки

  • ARCHITECTURE.md — слои и модули.
  • MULTI-TENANCY.md — query-filter, override-режим, расширенный чеклист «как добавить tenant-сущность».
  • RUNBOOK.md — операционные процедуры, recovery drill (RTO ~25с подтверждённый).
  • openapi.md — генерация TS-клиента из Swagger.
  • observability.md — Serilog + Prometheus + Grafana dashboard JSON (Sprint 13).
  • ofd-integration.md — ОФД-провайдеры (Sprint 11).
  • performance-baseline.md — k6-замеры (Sprint 12, 14).
  • secrets.md — где живут секреты.