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

481 lines
23 KiB
Markdown
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.

# Developer guide — food-market
Как поднять проект, что куда добавлять, какие паттерны соблюдать.
Предполагается, что вы прочитали [ARCHITECTURE.md](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-деплоя.
### Поднять с нуля
```bash
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
```bash
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
```
## Запуск тестов
```bash
# 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.
```csharp
// 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)` без явного `OrganizationId`
`StampTenant` в `SaveChangesAsync` подставит.
- Логирование структурированное: `{ProgramId}`, `{Name}`, `{OrgId}`
Serilog кладёт их как property'и (search'абельны в Loki/ES, не строкой).
### Если нужен Admin-only (грубее)
```csharp
[HttpPut, Authorize(Roles = "Admin")]
```
это эквивалентно «или Identity-role Admin, или SuperAdmin в режиме
override (через `SuperAdminOverrideClaimsTransformer`)». Подходит для
редких операций; для регулярных используй `RequiresPermission`.
## Паттерны: добавить сущность с RowVersion и tenant
Допустим, нужна новая сущность `PromoCode`.
### 1. Domain
```csharp
// 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
```csharp
// 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
```csharp
// food-market.infrastructure/Persistence/AppDbContext.cs
public DbSet<PromoCode> PromoCodes => Set<PromoCode>();
```
### 4. Миграция руками
```csharp
// 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
```csharp
public record ProductInput(
[Required, MaxLength(200)] string Name,
[Range(0, 1e10)] decimal Price);
```
### Сложные — FluentValidation
В `food-market.api/Infrastructure/Validation/Validators.cs`:
```csharp
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-метода:
```csharp
[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
```csharp
_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'я,
показ тоста):
```csharp
// в 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](ARCHITECTURE.md) — слои и модули.
- [MULTI-TENANCY.md](MULTI-TENANCY.md) — query-filter, override-режим,
расширенный чеклист «как добавить tenant-сущность».
- [RUNBOOK.md](RUNBOOK.md) — операционные процедуры, recovery drill
(RTO ~25с подтверждённый).
- [openapi.md](openapi.md) — генерация TS-клиента из Swagger.
- [observability.md](observability.md) — Serilog + Prometheus + Grafana
dashboard JSON (Sprint 13).
- [ofd-integration.md](ofd-integration.md) — ОФД-провайдеры (Sprint 11).
- [performance-baseline.md](performance-baseline.md) — k6-замеры (Sprint 12, 14).
- [secrets.md](secrets.md) — где живут секреты.