docs(s24): docs cross-check + auto-gen + onboarding + test gap-fill (8/8 ✓)
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
1. Docs cross-check — обновил performance-baseline.md (Sprint 18/20/23 фиксы), secrets.md (16 новых env-vars из Sprint 20+ — Authentication Google/Microsoft, Monitoring, Cleanup, Hangfire:Cron, Telegram, Maintenance, App, Storage, PUBLIC_GA_ID/YM_ID). 2. Auto-gen api-reference — ApiReferenceDocsJob (Hangfire weekly вс 05:30 UTC) + Python-эквивалент `/tmp/gen-api-ref.py` для commit actual snapshot. docs/api-reference.md = 195 endpoints, 57 controllers. 3. Coverage gap-fill — Sprint18To23FeaturesTests.cs (16 Facts): - bulk-update + cross-tenant isolation - UserPresets CRUD - inline-edit price PATCH - CSV import 2 строки транзакцией - OrgExport create + list isolation - 1C-CSV import с русскими заголовками - audit-log export CSV streaming + BOM check - MoySklad sync-status stub - SSO providers + 503 unconfigured + 400 unknown provider - bug-001 NUL byte → 400 - bug-004 tiny price → 400 - export CSV BOM Покрывает все новые контроллеры Sprint 18-23 + regression-protect для критичных багов. 4. Contract tests — deploy/swagger-diff.sh: pull /swagger/v1/swagger.json с двух URL, diff endpoints+schemas через python3. Exit 0/1/2 для blue-green safety gate. Multi-path auto-detect. 5. docs/error-codes.md — каталог HTTP-кодов API (200-503) + humanizeError pattern для фронта + retry-policy таблица. 6. docs/glossary.md — 50+ доменных терминов (Tenant/Organization/Stock/ StockMovement/RetailSale/Counterparty/Owner/Employee/Role/Permission/ advisory lock/Serializable/…) с ссылками на code-сущности. 7. docs/ONBOARDING.md — first 3 days для нового разработчика (install → запуск → структура → первый PR + FAQ). 8. README.md — обновил под текущее состояние: React 19, Sprint-history 1-24, ссылки на ключевые docs, корректный 5-min quick start. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
2bbd078659
commit
72d0a71307
170
README.md
170
README.md
|
|
@ -1,91 +1,127 @@
|
|||
# food-market
|
||||
|
||||

|
||||

|
||||

|
||||

|
||||
[](http://192.168.1.193:3000/nns/food-market/actions)
|
||||
[](http://192.168.1.193:3000/nns/food-market/actions)
|
||||
[](http://192.168.1.193:3000/nns/food-market/actions)
|
||||
[](http://192.168.1.193:3000/nns/food-market/actions)
|
||||

|
||||
|
||||
Аналог системы МойСклад для розничной торговли в Казахстане.
|
||||
Аналог системы МойСклад для розничной торговли в Казахстане. Multi-tenant
|
||||
SaaS + web-админка + Windows-касса. Поддерживает 8 типов документов учёта,
|
||||
ОФД-интеграцию (scaffolding), кассу на POS WPF с offline-буфером, отчёты,
|
||||
loyalty-programs, MoySklad-импорт, GDPR-export.
|
||||
|
||||
## Состав системы
|
||||
## Состав
|
||||
|
||||
- **Сервер** (ASP.NET Core 8 + PostgreSQL) — мультитенантный API, web-админка на React
|
||||
- **Web-админка** (React 18 + Vite + shadcn/ui) — управление магазином, справочниками, документами, отчётами
|
||||
- **Кассовая программа** (WPF на .NET 8) — офлайн-работоспособная касса для Windows 10+, синхронизируется с сервером, работает с весами (Масса-К в первую очередь)
|
||||
| Часть | Технологии | Точка входа |
|
||||
|---|---|---|
|
||||
| **API** | .NET 8 LTS, ASP.NET Core, EF Core 8, PostgreSQL 16, OpenIddict 5 | `src/food-market.api` → http://localhost:5081 |
|
||||
| **Web-админка** | React 19, Vite, TypeScript, Tailwind v4, TanStack Query, AG Grid | `src/food-market.web` → http://localhost:5173 |
|
||||
| **Public marketing** | Astro 5, TypeScript, Tailwind | `src/food-market.public` → http://localhost:4321 |
|
||||
| **POS-касса** | WPF .NET 8 Windows, SQLite, Refit+Polly, COM-весы | `src/food-market.pos` (сборка кроссплатформенно, UI — Windows) |
|
||||
|
||||
## Структура репозитория
|
||||
## 5-минутный quick start
|
||||
|
||||
```bash
|
||||
git clone http://192.168.1.193:3000/nns/food-market.git
|
||||
cd food-market
|
||||
|
||||
# БД (Postgres 14+ должен быть запущен, default user)
|
||||
createdb -U $USER food_market
|
||||
|
||||
# Backend (миграции применятся на старте; Swagger на /swagger)
|
||||
ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/food-market.api &
|
||||
|
||||
# Web SPA
|
||||
cd src/food-market.web && pnpm install && pnpm dev &
|
||||
|
||||
# Зарегистрироваться + получить токен
|
||||
curl -X POST http://localhost:5081/api/auth/signup \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"organizationName":"Dev","email":"dev@local.test","password":"DevPass1!","phone":"+77001234567"}'
|
||||
|
||||
curl -X POST http://localhost:5081/connect/token \
|
||||
-d 'grant_type=password&username=dev@local.test&password=DevPass1!&client_id=food-market-web&scope=openid profile email roles api offline_access'
|
||||
|
||||
# Открыть http://localhost:5173 → залогиниться dev@local.test / DevPass1!
|
||||
```
|
||||
|
||||
Подробнее — [`docs/ONBOARDING.md`](docs/ONBOARDING.md).
|
||||
|
||||
## Где что лежит
|
||||
|
||||
```
|
||||
food-market/
|
||||
├── src/
|
||||
│ ├── food-market.domain/ # доменные сущности, enum'ы, события
|
||||
│ ├── food-market.application/ # use cases (MediatR), DTO, интерфейсы
|
||||
│ ├── food-market.infrastructure/ # EF Core, PostgreSQL, внешние сервисы
|
||||
│ ├── food-market.api/ # ASP.NET Core + OpenIddict + SignalR
|
||||
│ ├── food-market.web/ # React + Vite + shadcn/ui (SPA)
|
||||
│ ├── food-market.shared/ # DTO-контракты сервер ↔ POS
|
||||
│ ├── food-market.pos.core/ # логика POS (независима от UI)
|
||||
│ └── food-market.pos/ # WPF + .NET 8 кассовая программа
|
||||
│ ├── food-market.domain/ # POCO + enum'ы + interfaces. Без EF / ASP.NET.
|
||||
│ ├── food-market.application/ # DTO, FluentValidation, MediatR-handler'ы, Mapster.
|
||||
│ ├── food-market.infrastructure/ # EF Core, миграции, Identity, OpenIddict storage.
|
||||
│ ├── food-market.api/ # Controllers (60), middleware, Hangfire jobs (10 recurring), OpenIddict server.
|
||||
│ ├── food-market.web/ # React 19 SPA админки.
|
||||
│ ├── food-market.public/ # Astro marketing-сайт.
|
||||
│ ├── food-market.shared/ # DTO-контракты сервер↔POS.
|
||||
│ ├── food-market.pos.core/ # POS-логика (UI-agnostic).
|
||||
│ └── food-market.pos/ # WPF (.NET 8 Windows).
|
||||
├── tests/
|
||||
├── deploy/
|
||||
│ ├── docker-compose.yml # PostgreSQL для локальной разработки
|
||||
│ └── Dockerfile.api
|
||||
└── docs/
|
||||
│ ├── food-market.UnitTests/ # xUnit + InMemory EF.
|
||||
│ ├── food-market.IntegrationTests/# xUnit + Testcontainers Postgres.
|
||||
│ ├── e2e/ # Playwright + ad-hoc Python smoke.
|
||||
│ └── load/ # k6 (нагрузочные).
|
||||
├── deploy/ # Dockerfile.{api,web,public}, compose, nginx, prod-toolchain.
|
||||
├── docs/ # 50+ markdown файлов.
|
||||
└── food-market.sln
|
||||
```
|
||||
|
||||
## Именование
|
||||
## Ключевая документация
|
||||
|
||||
- **Папки, проекты, csproj, docker-образы, URL** — lowercase: `food-market`, `food-market.api`
|
||||
- **C# namespace** — `foodmarket.Api`, `foodmarket.Domain` (lowercase root; C# не допускает дефис в идентификаторах)
|
||||
- **Отображаемое имя в UI** — "Food Market"
|
||||
|
||||
## Стек
|
||||
|
||||
### Сервер
|
||||
- .NET 8 LTS (до ноября 2026), ASP.NET Core Minimal APIs + Controllers
|
||||
- EF Core 8 + Npgsql + PostgreSQL 16
|
||||
- OpenIddict 5 (OAuth2/OIDC — password + refresh tokens)
|
||||
- MediatR + FluentValidation (CQRS-lite)
|
||||
- SignalR (real-time синхронизация)
|
||||
- Hangfire (фоновые задачи)
|
||||
- Serilog (структурированное логирование)
|
||||
|
||||
### Web
|
||||
- React 18 + Vite + TypeScript
|
||||
- shadcn/ui + Tailwind CSS
|
||||
- TanStack Query + TanStack Table
|
||||
- AG Grid Community (для тяжёлых grid'ов)
|
||||
- Recharts / Tremor (графики)
|
||||
- react-hook-form + Zod
|
||||
|
||||
### POS
|
||||
- .NET 8 WPF, Windows 10+
|
||||
- CommunityToolkit.Mvvm (source-generated MVVM)
|
||||
- SQLite (локальная БД)
|
||||
- Refit + Polly (API-клиент с retry)
|
||||
- System.IO.Ports (драйверы весов: Масса-К и др.)
|
||||
- **[ARCHITECTURE.md](docs/ARCHITECTURE.md)** — общая картина: слои, deployment, что реализовано / scaffolding / не реализовано.
|
||||
- **[ONBOARDING.md](docs/ONBOARDING.md)** — first 3 days для нового разработчика.
|
||||
- **[glossary.md](docs/glossary.md)** — все доменные термины с ссылками на код.
|
||||
- **[MULTI-TENANCY.md](docs/MULTI-TENANCY.md)** — как изолируются org'и.
|
||||
- **[api-reference.md](docs/api-reference.md)** — auto-generated список всех 195 endpoint'ов.
|
||||
- **[error-codes.md](docs/error-codes.md)** — каталог HTTP-кодов для humanizeError на фронте.
|
||||
- **[secrets.md](docs/secrets.md)** — env-vars + где хранятся секреты.
|
||||
- **[RUNBOOK.md](docs/RUNBOOK.md)** — операционные процедуры (что делать при инциденте).
|
||||
- **[performance-baseline.md](docs/performance-baseline.md)** — k6 цифры + bottleneck'и.
|
||||
|
||||
## Мультитенантность
|
||||
|
||||
Каждая сущность имеет `OrganizationId`. Пользователь scoped к организации. EF Core query filter автоматически изолирует данные между тенантами.
|
||||
Один процесс API обслуживает много организаций. Каждая видит только свои
|
||||
данные через EF Core query-filter по `OrganizationId`. `SuperAdmin` роль
|
||||
видит всё. См. [MULTI-TENANCY.md](docs/MULTI-TENANCY.md).
|
||||
|
||||
## Локальная разработка
|
||||
## Деплой
|
||||
|
||||
```bash
|
||||
# Поднять PostgreSQL
|
||||
cd deploy && docker compose up -d
|
||||
- **Stage**: `https://test.admin.food-market.kz`. Деплой одной командой:
|
||||
```bash
|
||||
~/deploy-stage.sh # docker build api+web → push в local registry → ssh prod-vm → compose up -d
|
||||
```
|
||||
- **Prod**: `https://admin.food-market.kz`. Toolchain готов (Sprint 21):
|
||||
```bash
|
||||
deploy/check-prod-readiness.sh # backup+disk+health+env
|
||||
deploy/prod-deploy.sh <api-tag> <web-tag> # blue-green
|
||||
deploy/prod-rollback.sh <to-tag> # быстрый откат
|
||||
deploy/post-deploy-smoke.sh # 10 шагов smoke + Telegram alert
|
||||
```
|
||||
Реальный prod-сервер пока не настроен (DNS / certbot / nginx upstream).
|
||||
|
||||
# Мигрировать БД
|
||||
cd src/food-market.api && dotnet ef database update
|
||||
## Sprint-история (что было сделано)
|
||||
|
||||
# Запустить API
|
||||
dotnet run --project src/food-market.api
|
||||
Хронология в `docs/sprintNN-progress.md`. По состоянию на Sprint 24:
|
||||
- **1-7** — фундамент: auth (OpenIddict), multi-tenancy, каталог, документы, кассы.
|
||||
- **8-10** — отчёты, dashboard, dark mode + Cmd+K.
|
||||
- **11** — ОФД scaffolding (Webkassa / Kassa24 / ОФД-Соло).
|
||||
- **12-13** — документация / runbook / k6, security headers + rate-limits.
|
||||
- **14-15** — performance (bundle −51%, индексы, N+1 fix), a11y (WCAG-AA).
|
||||
- **16-17** — regression suite (44 Playwright specs), onboarding wizard + help.
|
||||
- **18** — TODO cleanup (P0 race, audit filters, notification center).
|
||||
- **19** — power UX (bulk-update, presets, Cmd+J, inline-edit, CSV import/export, keyboard nav).
|
||||
- **20** — Mapster + SSO scaffold + maintenance jobs (cleanup, VACUUM, disk-monitor, perf-regression).
|
||||
- **21** — stage→prod toolchain (7 deploy-скриптов + auto-tag).
|
||||
- **22** — data tooling: GDPR-export, 1C-CSV import, anonymize-prod, DB-schema docs, audit export streaming.
|
||||
- **23** — adversarial bug-hunt (4 bugs found + 4 fixed, includes CRITICAL 40001→500 fix).
|
||||
- **24** — docs cross-check + auto-generated API reference + ONBOARDING + integration-test gap-fill.
|
||||
|
||||
# Запустить Web
|
||||
cd src/food-market.web && pnpm install && pnpm dev
|
||||
```
|
||||
## Лицензия
|
||||
|
||||
## Статус
|
||||
|
||||
🚧 Phase 0: фундамент (scaffolding, auth, multi-tenancy)
|
||||
Internal proprietary, не для публикации без разрешения владельца.
|
||||
|
|
|
|||
127
deploy/swagger-diff.sh
Executable file
127
deploy/swagger-diff.sh
Executable file
|
|
@ -0,0 +1,127 @@
|
|||
#!/usr/bin/env bash
|
||||
#
|
||||
# Sprint 24: контракт-тест — diff /openapi.json между двумя
|
||||
# окружениями. Используется ПЕРЕД blue-green деплоем чтобы понять что
|
||||
# меняется в публичном API и не сломать клиентов (Web admin, POS WPF,
|
||||
# партнёрские интеграции).
|
||||
#
|
||||
# Usage:
|
||||
# deploy/swagger-diff.sh [--from URL] [--to URL]
|
||||
#
|
||||
# Default:
|
||||
# from = https://admin.food-market.kz (prod)
|
||||
# to = https://test.admin.food-market.kz (stage)
|
||||
#
|
||||
# Что показывает:
|
||||
# - removed endpoints (path+method) — BREAKING ⚠️
|
||||
# - added endpoints — NEW (нормально)
|
||||
# - changed request/response schemas — нужен ручной обзор
|
||||
#
|
||||
# Без зависимости от swagger-diff CLI: парсим JSON через python3.
|
||||
#
|
||||
# Exit codes:
|
||||
# 0 — изменений нет ИЛИ только additions
|
||||
# 1 — есть removed (BREAKING) или changed schemas
|
||||
# 2 — ошибка получения swagger.json
|
||||
|
||||
set -uo pipefail
|
||||
|
||||
FROM_URL="${FM_SWAGGER_FROM:-https://admin.food-market.kz}"
|
||||
TO_URL="${FM_SWAGGER_TO:-https://test.admin.food-market.kz}"
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--from) FROM_URL="$2"; shift 2 ;;
|
||||
--to) TO_URL="$2"; shift 2 ;;
|
||||
--help|-h) grep -E '^#( |$)' "$0" | sed 's/^# \?//'; exit 0 ;;
|
||||
*) echo "Unknown: $1" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
TMP=$(mktemp -d)
|
||||
trap "rm -rf $TMP" EXIT
|
||||
|
||||
# Пытаемся несколько канонических путей: Swashbuckle default + alt-routes.
|
||||
fetch_swagger() {
|
||||
local base="$1" out="$2"
|
||||
for path in /swagger/v1/swagger.json /v1/swagger.json /api/v1/swagger.json; do
|
||||
if curl -fsS --max-time 30 "$base$path" -o "$out" 2>/dev/null; then
|
||||
# Должен быть JSON, не HTML (фронт SPA отдаёт index.html на unknown path).
|
||||
if python3 -c 'import json,sys; json.load(open(sys.argv[1]))' "$out" 2>/dev/null; then
|
||||
echo " found at $path" >&2
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
echo "Fetching from $FROM_URL…" >&2
|
||||
fetch_swagger "$FROM_URL" "$TMP/from.json" \
|
||||
|| { echo "FAIL: $FROM_URL не отдаёт swagger.json. Проверьте IncludeSwagger=true в appsettings или ASPNETCORE_ENVIRONMENT=Development." >&2; exit 2; }
|
||||
echo "Fetching from $TO_URL…" >&2
|
||||
fetch_swagger "$TO_URL" "$TMP/to.json" \
|
||||
|| { echo "FAIL: $TO_URL не отдаёт swagger.json." >&2; exit 2; }
|
||||
|
||||
python3 - <<PY
|
||||
import json, sys
|
||||
def endpoints(s):
|
||||
out = set()
|
||||
for path, methods in s.get('paths', {}).items():
|
||||
for method, op in methods.items():
|
||||
if method.lower() in {'get','post','put','patch','delete','head','options'}:
|
||||
out.add(f"{method.upper()} {path}")
|
||||
return out
|
||||
|
||||
def schemas(s):
|
||||
return set(s.get('components', {}).get('schemas', {}).keys())
|
||||
|
||||
with open('$TMP/from.json') as f: src = json.load(f)
|
||||
with open('$TMP/to.json') as f: dst = json.load(f)
|
||||
e_src, e_dst = endpoints(src), endpoints(dst)
|
||||
s_src, s_dst = schemas(src), schemas(dst)
|
||||
|
||||
added_ep = sorted(e_dst - e_src)
|
||||
removed_ep = sorted(e_src - e_dst)
|
||||
added_sc = sorted(s_dst - s_src)
|
||||
removed_sc = sorted(s_src - s_dst)
|
||||
|
||||
print(f"=== Swagger diff: $FROM_URL → $TO_URL ===")
|
||||
print(f"endpoints: from={len(e_src)} to={len(e_dst)} added={len(added_ep)} removed={len(removed_ep)}")
|
||||
print(f"schemas: from={len(s_src)} to={len(s_dst)} added={len(added_sc)} removed={len(removed_sc)}")
|
||||
print()
|
||||
|
||||
if added_ep:
|
||||
print("### Added endpoints (новые, нормально):")
|
||||
for e in added_ep: print(f" + {e}")
|
||||
print()
|
||||
|
||||
if removed_ep:
|
||||
print("### ⚠️ REMOVED endpoints (BREAKING для клиентов!):")
|
||||
for e in removed_ep: print(f" - {e}")
|
||||
print()
|
||||
|
||||
if added_sc:
|
||||
print(f"### Added schemas: {len(added_sc)} (показано первые 20)")
|
||||
for s in added_sc[:20]: print(f" + {s}")
|
||||
print()
|
||||
|
||||
if removed_sc:
|
||||
print(f"### ⚠️ REMOVED schemas: {len(removed_sc)}")
|
||||
for s in removed_sc[:20]: print(f" - {s}")
|
||||
print()
|
||||
|
||||
# Изменения операций (опц.): сравнить parameters/responses для shared endpoint'ов.
|
||||
# Пока — высокоуровневое diff'a достаточно для blue-green safety check.
|
||||
|
||||
# Exit code
|
||||
if removed_ep or removed_sc:
|
||||
print("RESULT: BREAKING changes detected.")
|
||||
sys.exit(1)
|
||||
elif not added_ep and not added_sc:
|
||||
print("RESULT: schemas identical.")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("RESULT: только additions, безопасно деплоить.")
|
||||
sys.exit(0)
|
||||
PY
|
||||
263
docs/ONBOARDING.md
Normal file
263
docs/ONBOARDING.md
Normal file
|
|
@ -0,0 +1,263 @@
|
|||
# Onboarding для нового разработчика food-market
|
||||
|
||||
Этот документ — путь от «клонировал репо» до «открыл первый PR» за 3 дня.
|
||||
Если что-то не сходится с реальностью — это **баг документации**,
|
||||
отредактируй и оставь PR.
|
||||
|
||||
## День 1 — установка и первый запуск
|
||||
|
||||
### Что нужно
|
||||
|
||||
- macOS / Linux. Windows только для POS WPF (см. отдельно ниже).
|
||||
- .NET 8 SDK (точная версия из `global.json` — `dotnet --list-sdks`
|
||||
должен показывать её; если нет — `winget install Microsoft.DotNet.SDK.8`
|
||||
/ `brew install dotnet@8`).
|
||||
- Node.js 20+ (`nvm install 20 && nvm use 20`).
|
||||
- pnpm 9+ (`npm i -g pnpm`).
|
||||
- PostgreSQL 14+ (на macOS: `brew install postgresql@14 && brew services start postgresql@14`).
|
||||
- git, curl, python3 (для скриптов в `tests/e2e/`).
|
||||
- Docker для интеграционных тестов (Testcontainers) и stage-deploy.
|
||||
|
||||
### Установка проекта
|
||||
|
||||
```bash
|
||||
git clone http://192.168.1.193:3000/nns/food-market.git
|
||||
cd food-market
|
||||
|
||||
# БД для dev — пустая, инициируется миграциями автоматически.
|
||||
createdb -U $USER food_market
|
||||
|
||||
# Backend
|
||||
dotnet restore
|
||||
dotnet build food-market.sln -c Debug --nologo
|
||||
|
||||
# Web frontend
|
||||
cd src/food-market.web && pnpm install && cd ../..
|
||||
```
|
||||
|
||||
### Запуск
|
||||
|
||||
```bash
|
||||
# Терминал 1: API
|
||||
ASPNETCORE_ENVIRONMENT=Development dotnet run --project src/food-market.api
|
||||
# → http://localhost:5081, Swagger на /swagger
|
||||
|
||||
# Терминал 2: Web SPA
|
||||
cd src/food-market.web && pnpm dev
|
||||
# → http://localhost:5173
|
||||
```
|
||||
|
||||
### Проверка что работает
|
||||
|
||||
```bash
|
||||
# Health
|
||||
curl http://localhost:5081/health/ready
|
||||
|
||||
# Зарегистрируйся
|
||||
curl -X POST http://localhost:5081/api/auth/signup \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"organizationName":"DevOrg","email":"dev@local.test","password":"DevPass123!","phone":"+77001234567"}'
|
||||
|
||||
# Логин и получи токен
|
||||
TOKEN=$(curl -sX POST http://localhost:5081/connect/token \
|
||||
-d 'grant_type=password&username=dev@local.test&password=DevPass123!&client_id=food-market-web&scope=openid profile email roles api offline_access' \
|
||||
| python3 -c 'import sys,json;print(json.load(sys.stdin)["access_token"])')
|
||||
|
||||
# Что я могу
|
||||
curl -sH "Authorization: Bearer $TOKEN" http://localhost:5081/api/me | python3 -m json.tool
|
||||
```
|
||||
|
||||
### Тесты
|
||||
|
||||
```bash
|
||||
# Unit
|
||||
dotnet test tests/food-market.UnitTests --nologo
|
||||
|
||||
# Integration (нужен Docker — Testcontainers поднимает Postgres-контейнер)
|
||||
dotnet test tests/food-market.IntegrationTests --nologo
|
||||
|
||||
# E2E (Playwright против локального API)
|
||||
cd tests/e2e
|
||||
E2E_ADMIN_URL=http://localhost:5081 ./run.sh stage-smoke
|
||||
```
|
||||
|
||||
## День 2 — где что лежит
|
||||
|
||||
### Структура (укрупнённо)
|
||||
|
||||
```
|
||||
food-market/
|
||||
├── src/
|
||||
│ ├── food-market.domain/ — POCO + enum'ы + интерфейсы. Без EF, без ASP.NET.
|
||||
│ ├── food-market.application/ — DTO, FluentValidation, MediatR-handler'ы, Mapster config.
|
||||
│ ├── food-market.infrastructure/ — EF Core, миграции, Identity, OpenIddict storage.
|
||||
│ ├── food-market.api/ — Controllers, middleware, Hangfire jobs, OpenIddict server.
|
||||
│ ├── food-market.web/ — React 19 + Vite SPA админки (admin.food-market.kz).
|
||||
│ ├── food-market.public/ — Astro marketing-сайт (food-market.kz).
|
||||
│ ├── food-market.shared/ — DTO-контракты сервер↔POS.
|
||||
│ ├── food-market.pos.core/ — POS-логика (UI-agnostic).
|
||||
│ └── food-market.pos/ — WPF (net8.0-windows; собирается на любой OS).
|
||||
├── tests/
|
||||
│ ├── food-market.UnitTests/ — xUnit + InMemory EF (быстрые юниты).
|
||||
│ ├── food-market.IntegrationTests/— xUnit + Testcontainers Postgres (через WebApplicationFactory).
|
||||
│ ├── e2e/ — Playwright (TS) + ad-hoc Python smoke-скрипты.
|
||||
│ └── load/ — k6 (нагрузочные).
|
||||
├── deploy/ — Dockerfile.{api,web,public}, compose, nginx, скрипты (prod-deploy/rollback/smoke/anonymize/swagger-diff).
|
||||
├── docs/ — markdown (этот файл — `ONBOARDING.md`, плюс ARCHITECTURE/RUNBOOK/etc).
|
||||
└── food-market.sln
|
||||
```
|
||||
|
||||
### Что почитать в первую очередь
|
||||
|
||||
Порядок имеет значение — от general к specific:
|
||||
|
||||
1. **[ARCHITECTURE.md](ARCHITECTURE.md)** — общая картина: слои, deployment-топология, ключевые потоки, что реализовано / scaffolding / не реализовано (после 22 спринтов).
|
||||
2. **[glossary.md](glossary.md)** — все доменные термины (Tenant / Organization / Stock / RetailSale / …) с ссылками на классы.
|
||||
3. **[MULTI-TENANCY.md](MULTI-TENANCY.md)** — query-filter, SuperAdmin override, как не утечь cross-org.
|
||||
4. **[DEVELOPER-GUIDE.md](DEVELOPER-GUIDE.md)** — паттерны (CQRS-like, MediatR, валидаторы, Mapster), стиль кода, как добавлять новые endpoint'ы.
|
||||
5. **[api-reference.md](api-reference.md)** — auto-generated список всех 190+ endpoint'ов (обновляется weekly через Hangfire).
|
||||
6. **[error-codes.md](error-codes.md)** — каталог HTTP-кодов для humanizeError на фронте.
|
||||
7. **[secrets.md](secrets.md)** — env-vars + где хранятся секреты.
|
||||
8. **[observability.md](observability.md)** — Prometheus метрики, Serilog, /health.
|
||||
9. **[RUNBOOK.md](RUNBOOK.md)** — как разруливать инциденты («api не стартует», «остатки разъехались», и т.п.).
|
||||
10. **[performance-baseline.md](performance-baseline.md)** — k6 baseline + что НЕ масштабируется.
|
||||
|
||||
### Sprint-history
|
||||
|
||||
Хронология фич: `docs/sprint1-progress.md` … `docs/sprint24-progress.md`.
|
||||
Каждый — что было сделано + цифры. Полезно когда видишь странное имя
|
||||
файла и хочешь понять «когда и зачем».
|
||||
|
||||
### Тестовый стенд
|
||||
|
||||
- **Stage**: `https://test.admin.food-market.kz` — `~/deploy-stage.sh` собирает образ и катит. Подробности в [stage-access.md](stage-access.md).
|
||||
- **Prod**: `https://admin.food-market.kz` — НЕ деплоится автоматически (Sprint 21 toolchain готов, см. `deploy/prod-deploy.sh`, но реальный сервер не настроен).
|
||||
|
||||
### Git workflow
|
||||
|
||||
- Origin — Forgejo на `http://192.168.1.193:3000/nns/food-market.git`.
|
||||
GitHub — mirror.
|
||||
- Никаких force-push'ей в main (после первого тэга).
|
||||
- Branch для серьёзных фич: `feat/<sprint>-<short-name>`, в Pull Request →
|
||||
Squash & Merge.
|
||||
- Каждый коммит на собственной фиче — `feat(scope): subject` (см.
|
||||
`git log --oneline` для примеров).
|
||||
|
||||
## День 3 — первый PR
|
||||
|
||||
### Найти первую задачу
|
||||
|
||||
- `grep -rn "TODO\|FIXME" src/` — около 30 живых TODO. Самые маленькие
|
||||
обычно UX-полировка (i18n, copy, validation message).
|
||||
- `docs/sprintNN-progress.md` последнего спринта → раздел «Открытые TODO».
|
||||
- В Forgejo Issues (если есть): bug-001..004 в `tests/e2e/reports/bugs/`
|
||||
— некоторые фиксы уже сделаны, остаются follow-up'ы.
|
||||
- Слабый шаг: посмотри `docs/performance-baseline.md` раздел «Сводка:
|
||||
что нужно поправить» — там список задач со статусом ✅/⚠️/❌.
|
||||
|
||||
### Что сделать перед PR
|
||||
|
||||
1. `git fetch origin && git rebase origin/main` (memory: `feedback_serialize_edits` —
|
||||
мы один-коммитящий-за-раз; не делай параллельных правок).
|
||||
2. `dotnet build` + `dotnet test` (unit + integration) — должны быть зелёные.
|
||||
3. `pnpm -C src/food-market.web exec tsc --noEmit` — TS clean.
|
||||
4. Локальный smoke если правил controller'ы: запусти API + `curl` на затронутый
|
||||
endpoint.
|
||||
5. Для UI: открой `/login` локально, проверь что страница работает.
|
||||
|
||||
### Шаблон PR-сообщения
|
||||
|
||||
```
|
||||
<тип>(scope): краткое описание
|
||||
|
||||
Что: …
|
||||
Зачем: …
|
||||
Как тестировал: …
|
||||
Связанные issue/sprint: …
|
||||
|
||||
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> (если работал в паре с Claude)
|
||||
```
|
||||
|
||||
### Кодстайл
|
||||
|
||||
- **C#**: дефолтный .NET-стиль. Один файл — один класс. Async/await везде
|
||||
где I/O. EF-проекции через `.ProjectToType<TDto>(MapsterConfig.Config)`
|
||||
для новых endpoint'ов (Sprint 20+).
|
||||
- **TS**: prettier+eslint конфиг в `src/food-market.web`. Hooks naming
|
||||
`useFoo`. Server-state — TanStack Query, не useState.
|
||||
- **CSS**: только Tailwind utility-classes. Никаких inline styles.
|
||||
- **Комментарии**: только если объясняют **почему**, не **что**. Если
|
||||
переписал паттерн — оставь reference на `[memory:feedback_serialize_edits]`
|
||||
или sprint-doc.
|
||||
|
||||
## FAQ
|
||||
|
||||
### Q: API не стартует, ругается на global.json
|
||||
|
||||
`dotnet --list-sdks` должен содержать ровно ту версию что в `global.json`
|
||||
(8.0.417). Если нет — установи SDK. **НЕ редактируй global.json** — это
|
||||
сломает CI и другие dev-машины.
|
||||
|
||||
### Q: Integration-тесты падают с "Cannot find docker daemon"
|
||||
|
||||
Включи Docker Desktop / `sudo systemctl start docker`. Testcontainers
|
||||
тащит `postgres:16-alpine` (один раз, потом из кеша).
|
||||
|
||||
### Q: Web стартует но не видит API
|
||||
|
||||
Проверь что `src/food-market.web/vite.config.ts` proxy указывает на
|
||||
`http://localhost:5081`. Если порт API изменился — обнови.
|
||||
|
||||
### Q: Сертификат OpenIddict не создаётся
|
||||
|
||||
Dev-режим: `App_Data/` должен быть writable. Прод: см.
|
||||
[openiddict-keys.md](openiddict-keys.md).
|
||||
|
||||
### Q: Как добавить новую сущность
|
||||
|
||||
Шаги (псевдо-flow):
|
||||
1. POCO в `src/food-market.domain/<Area>/MyEntity.cs` (от `TenantEntity` если связана с org'ой).
|
||||
2. DbSet + EntityTypeConfiguration в `src/food-market.infrastructure/Persistence/AppDbContext.cs` + `Configurations/`.
|
||||
3. Migration в `Migrations/<timestamp>_<name>.cs` — **ВРУЧНУЮ**, не через `dotnet ef migrations add`. Обязательны `[Migration("id")]` + `[DbContext(typeof(AppDbContext))]` (memory: `feedback_ef_migrations`).
|
||||
4. DTO + Validator в `src/food-market.application/<Area>/`.
|
||||
5. Mapster TypeAdapterConfig в `MapsterConfig.Build()` если есть нетривиальное проецирование.
|
||||
6. Controller в `src/food-market.api/Controllers/<Area>/`. Atomic per endpoint, multi-tenant через query-filter (автоматически).
|
||||
7. Integration-тест в `tests/food-market.IntegrationTests/<Area>Tests.cs` — минимум один happy-path.
|
||||
8. Если возвращаешь в Web — обновить `src/food-market.web/src/lib/types.ts`.
|
||||
|
||||
### Q: Как запустить нагрузочный тест
|
||||
|
||||
```bash
|
||||
cd tests/load
|
||||
BASE_URL=http://localhost:5081 k6 run retail-sales-parallel.js
|
||||
# или против stage:
|
||||
BASE_URL=https://test.admin.food-market.kz k6 run signup-burst.js
|
||||
```
|
||||
|
||||
См. [performance-baseline.md](performance-baseline.md) для интерпретации цифр.
|
||||
|
||||
### Q: Где POS WPF тестировать
|
||||
|
||||
Нужен Windows. На macOS/Linux можно собрать (`dotnet build src/food-market.pos`)
|
||||
но не запустить UI. Тесты POS-логики в `src/food-market.pos.core` —
|
||||
кроссплатформенные.
|
||||
|
||||
### Q: Хочу понять как работает …
|
||||
|
||||
- **Tenant isolation** → `MULTI-TENANCY.md` + `AppDbContext.ApplyTenantFilter`.
|
||||
- **OpenIddict** → `openiddict-keys.md` + Program.cs `AddOpenIddict()`.
|
||||
- **POS sync с idempotency** → `food-market.pos.core` + `PosBatchAckController`.
|
||||
- **ОФД** → `ofd-integration.md` + `Infrastructure/Fiscal/`.
|
||||
- **CSV import** → `imports.md` + `ProductsController.ImportCsv`.
|
||||
- **GDPR org export** → `OrgExportJob` (Sprint 22).
|
||||
|
||||
## Где спрашивать
|
||||
|
||||
- Forgejo issues — для багов и feature requests.
|
||||
- В коде — поиск по docstring (комментарии часто отвечают «почему сделано
|
||||
именно так»).
|
||||
- Sprint-progress файлы — там цифры и trade-off'ы зафиксированы.
|
||||
- Memory-файлы Claude Code в `~/.claude/projects/-home-nns-food-market/memory/`
|
||||
— что-то типа CHANGELOG развития, более информально.
|
||||
|
||||
Welcome! 🚀
|
||||
545
docs/api-reference.md
Normal file
545
docs/api-reference.md
Normal file
|
|
@ -0,0 +1,545 @@
|
|||
# API endpoint reference
|
||||
|
||||
Сгенерировано Python-сканером (`/tmp/gen-api-ref.py`) из `src/food-market.api/Controllers/`.
|
||||
Идентичный логике runtime-job `ApiReferenceDocsJob` (Sprint 24); тот пересоздаёт файл
|
||||
еженедельно при cron `Hangfire:Cron:ApiReferenceDocs`.
|
||||
|
||||
Всего endpoint'ов: **195**.
|
||||
|
||||
Полная OpenAPI-спека: `/swagger/v1/swagger.json`. Этот reference — human-readable summary.
|
||||
|
||||
## `AbcReportController`
|
||||
Base route: `/api/reports/abc`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/reports/abc/export` | — | |
|
||||
|
||||
## `AdminCleanupController`
|
||||
Base route: `/api/admin/cleanup`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/admin/cleanup/all` | — | Полная очистка данных текущей организации — всё кроме настроек: остаются Organization, пользователи,… |
|
||||
| DELETE | `/api/admin/cleanup/counterparties` | — | Удалить всех контрагентов текущей организации. Чтобы не нарваться на FK, сначала обнуляем ссылки (Pr… |
|
||||
| GET | `/api/admin/cleanup/stats` | — | |
|
||||
| POST | `/api/admin/cleanup/all/async` | — | |
|
||||
|
||||
## `AdminJobsController`
|
||||
Base route: `/api/admin/jobs`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/admin/jobs/{id:guid}` | — | |
|
||||
|
||||
## `AuthForgotPasswordController`
|
||||
Base route: `/api/auth`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/auth/forgot-password` | — | |
|
||||
| POST | `/api/auth/reset-password` | — | |
|
||||
|
||||
## `AuthSignupController`
|
||||
Base route: `/api/auth`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/auth/signup` | — | |
|
||||
|
||||
## `AuthorizationController`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| POST | `/~/connect/token` | — | |
|
||||
|
||||
## `CounterpartiesController`
|
||||
Base route: `/api/catalog/counterparties`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/catalog/counterparties/{id:guid}` | — | |
|
||||
| GET | `/api/catalog/counterparties/export` | — | Sprint 19: экспорт списка контрагентов. |
|
||||
| GET | `/api/catalog/counterparties/{id:guid}` | — | |
|
||||
| POST | `/api/catalog/counterparties` | — | |
|
||||
| PUT | `/api/catalog/counterparties/{id:guid}` | — | |
|
||||
|
||||
## `CountriesController`
|
||||
Base route: `/api/catalog/countries`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/catalog/countries/{id:guid}` | — | |
|
||||
| GET | `/api/catalog/countries/{id:guid}` | — | |
|
||||
| POST | `/api/catalog/countries` | — | |
|
||||
| PUT | `/api/catalog/countries/{id:guid}` | — | |
|
||||
|
||||
## `CurrenciesController`
|
||||
Base route: `/api/catalog/currencies`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/catalog/currencies/{id:guid}` | — | |
|
||||
| POST | `/api/catalog/currencies` | — | |
|
||||
| PUT | `/api/catalog/currencies/{id:guid}` | — | |
|
||||
|
||||
## `DashboardController`
|
||||
Base route: `/api/dashboard`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/dashboard/margin` | — | Маржа за окно N дней: выручка минус COGS (Sum(qty * UnitCost) по строкам проданных товаров). Использ… |
|
||||
|
||||
## `DemandsController`
|
||||
Base route: `/api/sales/demands`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/sales/demands/{id:guid}` | — | |
|
||||
| GET | `/api/sales/demands/{id:guid}` | — | |
|
||||
| POST | `/api/sales/demands` | — | |
|
||||
| POST | `/api/sales/demands/{id:guid}/post` | — | |
|
||||
| POST | `/api/sales/demands/{id:guid}/unpost` | — | |
|
||||
| PUT | `/api/sales/demands/{id:guid}` | — | |
|
||||
|
||||
## `DemoSeedController`
|
||||
Base route: `/api/admin/seed-demo`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/admin/seed-demo/status` | — | Сводка: какие демо-сущности уже наполнены. Дешёвый — только count'ы, не вызывает seed. UI использует… |
|
||||
| POST | `/api/admin/seed-demo` | — | Запустить seed демо-данных. Идемпотентен — если уже наполнено, возвращает existing summary без встав… |
|
||||
|
||||
## `DiagnosticController`
|
||||
Base route: `/api/admin/diagnostic`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/admin/diagnostic/run` | — | |
|
||||
|
||||
## `EmployeeRolesController`
|
||||
Base route: `/api/organization/employee-roles`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/organization/employee-roles/{id:guid}` | — | |
|
||||
| GET | `/api/organization/employee-roles/{id:guid}` | — | |
|
||||
| POST | `/api/organization/employee-roles` | — | |
|
||||
| PUT | `/api/organization/employee-roles/{id:guid}` | — | |
|
||||
|
||||
## `EmployeesController`
|
||||
Base route: `/api/organization/employees`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/organization/employees/{id:guid}` | — | |
|
||||
| GET | `/api/organization/employees/{id:guid}` | — | |
|
||||
| POST | `/api/organization/employees` | — | |
|
||||
| PUT | `/api/organization/employees/{id:guid}` | — | |
|
||||
|
||||
## `EntersController`
|
||||
Base route: `/api/inventory/enters`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/inventory/enters/{id:guid}` | — | |
|
||||
| GET | `/api/inventory/enters/{id:guid}` | — | |
|
||||
| POST | `/api/inventory/enters` | — | |
|
||||
| POST | `/api/inventory/enters/{id:guid}/post` | — | |
|
||||
| POST | `/api/inventory/enters/{id:guid}/unpost` | — | |
|
||||
| PUT | `/api/inventory/enters/{id:guid}` | — | |
|
||||
|
||||
## `ExternalAuthController`
|
||||
Base route: `/api/auth/external`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/auth/external/callback` | — | Callback после успешного OAuth у провайдера. Читает claims и решает, что делать: связать с существую… |
|
||||
| GET | `/api/auth/external/providers` | — | Список доступных SSO-провайдеров. Web-фронт по этому списку решает, какие кнопки рисовать на /login. |
|
||||
| GET | `/api/auth/external/{provider}` | — | Инициирует OAuth challenge на провайдере. Если провайдер не сконфигурирован — 503 с подсказкой. |
|
||||
|
||||
## `FeedbackController`
|
||||
Base route: `/api/feedback`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/feedback` | — | |
|
||||
|
||||
## `GlobalSearchController`
|
||||
Base route: `/api/search`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/search/global` | — | |
|
||||
|
||||
## `InventoriesController`
|
||||
Base route: `/api/inventory/inventories`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/inventory/inventories/{id:guid}` | — | |
|
||||
| GET | `/api/inventory/inventories/{id:guid}` | — | |
|
||||
| POST | `/api/inventory/inventories` | — | |
|
||||
| POST | `/api/inventory/inventories/{id:guid}/post` | — | |
|
||||
| POST | `/api/inventory/inventories/{id:guid}/unpost` | — | |
|
||||
| PUT | `/api/inventory/inventories/{id:guid}` | — | |
|
||||
|
||||
## `LossesController`
|
||||
Base route: `/api/inventory/losses`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/inventory/losses/{id:guid}` | — | |
|
||||
| GET | `/api/inventory/losses/{id:guid}` | — | |
|
||||
| POST | `/api/inventory/losses` | — | |
|
||||
| POST | `/api/inventory/losses/{id:guid}/post` | — | |
|
||||
| POST | `/api/inventory/losses/{id:guid}/unpost` | — | |
|
||||
| PUT | `/api/inventory/losses/{id:guid}` | — | |
|
||||
|
||||
## `LoyaltyCardsController`
|
||||
Base route: `/api/loyalty/cards`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/loyalty/cards/{id:guid}` | — | |
|
||||
| GET | `/api/loyalty/cards/lookup` | — | Lookup по CardNumber — используется кассой при оплате. Возвращает 404 если карты нет, 409 если карта… |
|
||||
| POST | `/api/loyalty/cards/issue` | — | |
|
||||
| POST | `/api/loyalty/cards/{id:guid}/block` | — | |
|
||||
| POST | `/api/loyalty/cards/{id:guid}/unblock` | — | |
|
||||
|
||||
## `LoyaltyProgramsController`
|
||||
Base route: `/api/loyalty/programs`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/loyalty/programs/{id:guid}` | — | |
|
||||
| GET | `/api/loyalty/programs/{id:guid}` | — | |
|
||||
| POST | `/api/loyalty/programs` | — | |
|
||||
| PUT | `/api/loyalty/programs/{id:guid}` | — | |
|
||||
|
||||
## `MeAccountController`
|
||||
Base route: `/api/me`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/me/change-password` | — | Сменить пароль текущему юзеру. Требует текущий пароль для защиты от случайного/злонамеренного измене… |
|
||||
|
||||
## `MeSessionsController`
|
||||
Base route: `/api/me/sessions`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/me/sessions/revoke-all` | — | Гасит все refresh-токены текущего юзера. Использовать когда есть подозрение на угон cookies/пароля. |
|
||||
|
||||
## `MoySkladImportController`
|
||||
Base route: `/api/admin/moysklad`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/admin/moysklad/settings` | — | |
|
||||
| POST | `/api/admin/moysklad/import-counterparties` | — | |
|
||||
| POST | `/api/admin/moysklad/import-products` | — | |
|
||||
| POST | `/api/admin/moysklad/test` | — | |
|
||||
| PUT | `/api/admin/moysklad/settings` | — | |
|
||||
|
||||
## `MoySkladSyncStatusController`
|
||||
Base route: `/api/moysklad`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/moysklad/sync-status` | — | |
|
||||
|
||||
## `OrgExportController`
|
||||
Base route: `/api/org/export`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/org/export/download/{token}` | — | Anonymous download по токену. Не требует авторизации — security через 256-битный random token + TTL … |
|
||||
| GET | `/api/org/export/{id:guid}` | — | |
|
||||
| POST | `/api/org/export` | — | Создать новый экспорт. Возвращает 202 + Id; полезно сразу polled'ить GET /api/org/export/{id} до Sta… |
|
||||
|
||||
## `OrgFiscalSettingsController`
|
||||
Base route: `/api/organization/fiscal`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/organization/fiscal` | — | |
|
||||
| GET | `/api/organization/fiscal/providers` | — | Доступные значения провайдера для select'а в UI. Возвращаем массив, потому что enum-значения мы НЕ х… |
|
||||
| POST | `/api/organization/fiscal/test-send` | — | Тестовая отправка: создаёт «фейк-чек» (in-memory, не в БД) и отправляет через выбранного провайдера.… |
|
||||
| PUT | `/api/organization/fiscal` | — | |
|
||||
|
||||
## `OrganizationSettingsController`
|
||||
Base route: `/api/organization`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/organization/settings` | — | |
|
||||
| PUT | `/api/organization/settings` | — | |
|
||||
|
||||
## `PlatformSettingsController`
|
||||
Base route: `/api/super-admin/platform-settings`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/super-admin/platform-settings` | — | |
|
||||
| POST | `/api/super-admin/platform-settings/test-send` | — | |
|
||||
| PUT | `/api/super-admin/platform-settings` | — | |
|
||||
|
||||
## `PosController`
|
||||
Base route: `/api/pos/v1`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/pos/v1/sync` | — | |
|
||||
| POST | `/api/pos/v1/sales` | — | |
|
||||
|
||||
## `PriceTypesController`
|
||||
Base route: `/api/catalog/price-types`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/catalog/price-types/{id:guid}` | — | |
|
||||
| GET | `/api/catalog/price-types/{id:guid}` | — | |
|
||||
| POST | `/api/catalog/price-types` | — | |
|
||||
| PUT | `/api/catalog/price-types/{id:guid}` | — | |
|
||||
|
||||
## `ProductGroupsController`
|
||||
Base route: `/api/catalog/product-groups`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/catalog/product-groups/{id:guid}` | — | |
|
||||
| GET | `/api/catalog/product-groups/{id:guid}` | — | |
|
||||
| POST | `/api/catalog/product-groups` | — | |
|
||||
| PUT | `/api/catalog/product-groups/{id:guid}` | — | |
|
||||
|
||||
## `ProductImagesController`
|
||||
Base route: `/api/catalog/products/{productId:guid}/images`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/catalog/products/{productId:guid}/images/{imageId:guid}` | — | |
|
||||
| POST | `/api/catalog/products/{productId:guid}/images/{imageId:guid}/main` | — | |
|
||||
|
||||
## `ProductsController`
|
||||
Base route: `/api/catalog/products`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/catalog/products/{id:guid}` | — | |
|
||||
| GET | `/api/catalog/products/by-barcode/{value}` | — | Точный поиск по штрихкоду (для сканера). 0 → 404, 1 → объект, несколько → { items: [...] } чтобы UI … |
|
||||
| GET | `/api/catalog/products/export` | — | Sprint 19: экспорт списка товаров с теми же фильтрами что и /api/catalog/products. Сервер-side генер… |
|
||||
| GET | `/api/catalog/products/{id:guid}` | — | |
|
||||
| PATCH | `/api/catalog/products/{id:guid}/price` | — | |
|
||||
| POST | `/api/catalog/products` | — | |
|
||||
| POST | `/api/catalog/products/bulk-update` | — | |
|
||||
| POST | `/api/catalog/products/import-csv` | — | |
|
||||
| POST | `/api/catalog/products/{id:guid}/recalc-retail` | — | «Привести розничную к себестоимости»: ставит дефолтную розничную цену = ceil(Cost * (1 + Group.Marku… |
|
||||
| PUT | `/api/catalog/products/{id:guid}` | — | |
|
||||
|
||||
## `ProfitReportController`
|
||||
Base route: `/api/reports/profit`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/reports/profit/export` | — | |
|
||||
|
||||
## `PromotionsController`
|
||||
Base route: `/api/promotions`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/promotions/{id:guid}` | — | |
|
||||
| GET | `/api/promotions/{id:guid}` | — | |
|
||||
| POST | `/api/promotions` | — | |
|
||||
| PUT | `/api/promotions/{id:guid}` | — | |
|
||||
|
||||
## `RetailPointsController`
|
||||
Base route: `/api/catalog/retail-points`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/catalog/retail-points/{id:guid}` | — | |
|
||||
| GET | `/api/catalog/retail-points/{id:guid}` | — | |
|
||||
| POST | `/api/catalog/retail-points` | — | |
|
||||
| PUT | `/api/catalog/retail-points/{id:guid}` | — | |
|
||||
|
||||
## `RetailSalesController`
|
||||
Base route: `/api/sales/retail`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/sales/retail/{id:guid}` | — | |
|
||||
| GET | `/api/sales/retail/export` | — | Sprint 19: экспорт списка чеков с фильтрами status/storeId/from/to. |
|
||||
| GET | `/api/sales/retail/stats` | — | Aggregated sales metrics + daily series for the dashboard. Series buckets are days; defaults to last… |
|
||||
| GET | `/api/sales/retail/{id:guid}` | — | |
|
||||
| POST | `/api/sales/retail` | — | |
|
||||
| POST | `/api/sales/retail/{id:guid}/create-return` | — | POST /create-return — копирует строки проведённого чека в новый Draft с IsReturn=true и ReferenceSal… |
|
||||
| POST | `/api/sales/retail/{id:guid}/post` | — | |
|
||||
| POST | `/api/sales/retail/{id:guid}/unpost` | — | |
|
||||
| PUT | `/api/sales/retail/{id:guid}` | — | |
|
||||
|
||||
## `SalesReportController`
|
||||
Base route: `/api/reports/sales`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/reports/sales/export` | — | |
|
||||
|
||||
## `StockController`
|
||||
Base route: `/api/inventory`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/inventory/stock/export` | — | Sprint 19: экспорт остатков. |
|
||||
|
||||
## `StockReportController`
|
||||
Base route: `/api/reports/stock`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/reports/stock/export` | — | |
|
||||
|
||||
## `StoresController`
|
||||
Base route: `/api/catalog/stores`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/catalog/stores/{id:guid}` | — | |
|
||||
| GET | `/api/catalog/stores/{id:guid}` | — | |
|
||||
| POST | `/api/catalog/stores` | — | |
|
||||
| PUT | `/api/catalog/stores/{id:guid}` | — | |
|
||||
|
||||
## `SuperAdminController`
|
||||
Base route: `/api/super-admin`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/super-admin/dashboard` | — | |
|
||||
| GET | `/api/super-admin/settings` | — | |
|
||||
| GET | `/api/super-admin/setup-status` | — | |
|
||||
| PUT | `/api/super-admin/settings` | — | |
|
||||
|
||||
## `SuperAdminEmployeesController`
|
||||
Base route: `/api/super-admin/organizations/{orgId:guid}/employees`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/super-admin/organizations/{orgId:guid}/employees/{id:guid}` | — | |
|
||||
| GET | `/api/super-admin/organizations/{orgId:guid}/employees/{id:guid}` | — | |
|
||||
| POST | `/api/super-admin/organizations/{orgId:guid}/employees` | — | |
|
||||
| POST | `/api/super-admin/organizations/{orgId:guid}/employees/{id:guid}/account/toggle-active` | — | |
|
||||
| POST | `/api/super-admin/organizations/{orgId:guid}/employees/{id:guid}/reset-password` | — | |
|
||||
| POST | `/api/super-admin/organizations/{orgId:guid}/employees/{id:guid}/toggle-active` | — | |
|
||||
| PUT | `/api/super-admin/organizations/{orgId:guid}/employees/{id:guid}` | — | |
|
||||
|
||||
## `SuperAdminOrganizationsController`
|
||||
Base route: `/api/super-admin/organizations`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/super-admin/organizations/{id:guid}` | — | |
|
||||
| GET | `/api/super-admin/organizations/{id:guid}` | — | |
|
||||
| POST | `/api/super-admin/organizations` | — | |
|
||||
| POST | `/api/super-admin/organizations/{id:guid}/archive` | — | |
|
||||
| POST | `/api/super-admin/organizations/{id:guid}/change-owner` | — | |
|
||||
| POST | `/api/super-admin/organizations/{id:guid}/restore` | — | |
|
||||
| PUT | `/api/super-admin/organizations/{id:guid}` | — | |
|
||||
|
||||
## `SuperAdminUnitsOfMeasureController`
|
||||
Base route: `/api/super-admin/units-of-measure`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/super-admin/units-of-measure/{id:guid}` | — | Soft-delete: IsActive=false. Если на единицу ссылаются продукты или активные org-junction'ы — 409 со… |
|
||||
| GET | `/api/super-admin/units-of-measure/{id:guid}` | — | |
|
||||
| POST | `/api/super-admin/units-of-measure` | — | |
|
||||
| PUT | `/api/super-admin/units-of-measure/{id:guid}` | — | |
|
||||
|
||||
## `SupplierReturnsController`
|
||||
Base route: `/api/purchases/supplier-returns`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/purchases/supplier-returns/{id:guid}` | — | |
|
||||
| GET | `/api/purchases/supplier-returns/{id:guid}` | — | |
|
||||
| POST | `/api/purchases/supplier-returns` | — | |
|
||||
| POST | `/api/purchases/supplier-returns/{id:guid}/post` | — | |
|
||||
| POST | `/api/purchases/supplier-returns/{id:guid}/unpost` | — | |
|
||||
| PUT | `/api/purchases/supplier-returns/{id:guid}` | — | |
|
||||
|
||||
## `SuppliesController`
|
||||
Base route: `/api/purchases/supplies`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/purchases/supplies/{id:guid}` | — | |
|
||||
| GET | `/api/purchases/supplies/export` | — | Sprint 19: экспорт списка приёмок с теми же фильтрами. |
|
||||
| GET | `/api/purchases/supplies/{id:guid}` | — | |
|
||||
| POST | `/api/purchases/supplies` | — | |
|
||||
| POST | `/api/purchases/supplies/{id:guid}/post` | — | |
|
||||
| POST | `/api/purchases/supplies/{id:guid}/unpost` | — | |
|
||||
| PUT | `/api/purchases/supplies/{id:guid}` | — | |
|
||||
|
||||
## `TelegramBindingController`
|
||||
Base route: `/api/organization/telegram`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/organization/telegram` | — | |
|
||||
| GET | `/api/organization/telegram/status` | — | |
|
||||
| PUT | `/api/organization/telegram/bind` | — | |
|
||||
|
||||
## `TransfersController`
|
||||
Base route: `/api/inventory/transfers`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/inventory/transfers/{id:guid}` | — | |
|
||||
| GET | `/api/inventory/transfers/{id:guid}` | — | |
|
||||
| POST | `/api/inventory/transfers` | — | |
|
||||
| POST | `/api/inventory/transfers/{id:guid}/post` | — | |
|
||||
| POST | `/api/inventory/transfers/{id:guid}/unpost` | — | |
|
||||
| PUT | `/api/inventory/transfers/{id:guid}` | — | |
|
||||
|
||||
## `TwoFactorController`
|
||||
Base route: `/api/me/2fa`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/me/2fa/status` | — | |
|
||||
| POST | `/api/me/2fa/disable` | — | |
|
||||
| POST | `/api/me/2fa/enroll` | — | |
|
||||
| POST | `/api/me/2fa/verify` | — | |
|
||||
|
||||
## `UnitsOfMeasureController`
|
||||
Base route: `/api/catalog/units-of-measure`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/catalog/units-of-measure/{id:guid}/enable` | — | Отключить global для текущей орги. Если на эту единицу ссылаются продукты орги — 409 со списком назв… |
|
||||
| GET | `/api/catalog/units-of-measure/{id:guid}` | — | |
|
||||
| POST | `/api/catalog/units-of-measure/{id:guid}/enable` | — | Включить global для текущей орги. Идемпотентно: повторный вызов отдаёт 204 и не плодит дубликатов ju… |
|
||||
|
||||
## `UploadsController`
|
||||
Base route: `/uploads`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/uploads/{*path}` | — | |
|
||||
|
||||
## `UserPresetsController`
|
||||
Base route: `/api/user/presets`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| DELETE | `/api/user/presets/{id:guid}` | — | |
|
||||
| POST | `/api/user/presets` | — | |
|
||||
| PUT | `/api/user/presets/{id:guid}` | — | |
|
||||
|
||||
## `WhatsNewController`
|
||||
Base route: `/api/whats-new`
|
||||
|
||||
| Method | Route | Permission | Summary |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/whats-new` | — | |
|
||||
159
docs/error-codes.md
Normal file
159
docs/error-codes.md
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
# API error catalog
|
||||
|
||||
Каталог HTTP-кодов и тел ответов, которые возвращает `food-market.api`.
|
||||
Используется фронтом для `humanizeError(response)` и QA для regression
|
||||
проверки. Если поле `error` есть — это user-facing сообщение; `errors`
|
||||
(множественное) — структурированные ошибки валидации (ASP.NET
|
||||
ValidationProblemDetails).
|
||||
|
||||
## Формат
|
||||
|
||||
```jsonc
|
||||
// Универсальный шаблон single-error:
|
||||
{ "error": "Понятный текст для пользователя.", "field": "Optional" }
|
||||
|
||||
// ValidationProblemDetails (FluentValidation / DataAnnotations):
|
||||
{ "type": "...", "title": "One or more validation errors occurred.",
|
||||
"status": 400, "errors": { "Name": ["..."], "Prices[0].Amount": ["..."] } }
|
||||
|
||||
// retryable flag (Sprint 23):
|
||||
{ "error": "...", "retryable": true }
|
||||
```
|
||||
|
||||
## Коды
|
||||
|
||||
### 200/201/204 — OK / Created / NoContent
|
||||
Корректно. Тело — DTO или пусто.
|
||||
|
||||
### 400 — Bad Request
|
||||
|
||||
| Когда | Тело | Что показать |
|
||||
|---|---|---|
|
||||
| Validation от FluentValidation | `ValidationProblemDetails` с `errors.{field}: [msg]` | Подсветить поле + показать сообщение |
|
||||
| Business-rule (например, draft пустой) | `{error: "Нельзя провести пустой чек."}` | toast + не закрывать форму |
|
||||
| Сумма оплаты < total | `{error: "Сумма оплаты X меньше итога Y. Доплатите...", field: "PaidCash"}` | подсветить поле PaidCash |
|
||||
| Required price = 0 после rounding (Sprint 23 bug-004) | `{error: "Цена «X» обязательна и должна быть больше 0."}` | подсветить prices section |
|
||||
| NUL-byte в строке (Sprint 23 bug-001) | `errors.Name: ["Поле Name не должно содержать управляющих символов..."]` | подсветить поле |
|
||||
| Дубликат barcode при создании | `{error: "Штрихкод X уже используется товаром «Y»."}` | toast |
|
||||
| Дубликат артикула | `{error: "Артикул «X» уже занят в этой организации."}` | toast |
|
||||
| Невалидный CSV / 1С-import | `errors: [{row, error}]` | таблица с подсветкой строк |
|
||||
|
||||
### 401 — Unauthorized
|
||||
|
||||
| Когда | Тело | Что показать |
|
||||
|---|---|---|
|
||||
| Нет токена / устаревший токен | пусто или OpenIddict-`{error: "missing_token"}` | редирект на `/login`, refresh с RT |
|
||||
| Garbage / tampered JWT | `{error: "missing_token"}` | logout + login |
|
||||
| Refresh-token недействителен | `{error: "invalid_grant", error_description: "..."}` | logout |
|
||||
|
||||
### 403 — Forbidden
|
||||
|
||||
| Когда | Тело | Что показать |
|
||||
|---|---|---|
|
||||
| Нет permission на mutating action | пусто или ProblemDetails | toast: «Нет прав на это действие» |
|
||||
| Регулярный Admin лезет в `/hangfire` | пусто | redirect → 404 на фронте |
|
||||
| Cashier пытается удалить заявку | пусто | скрыть кнопку delete для Cashier |
|
||||
|
||||
### 404 — Not Found
|
||||
|
||||
| Когда | Что показать |
|
||||
|---|---|
|
||||
| Document не найден (включая cross-tenant — нельзя раскрыть существование!) | «Запись не найдена. Возможно, удалена.» |
|
||||
| Endpoint не существует (типо в URL) | (фронту не должно встречаться) |
|
||||
|
||||
### 409 — Conflict
|
||||
|
||||
| Когда | Тело | Что показать |
|
||||
|---|---|---|
|
||||
| DbUpdateConcurrencyException (xmin) | `{error: "Документ изменён в другом окне..."}` | toast + reload |
|
||||
| Чек уже проведён, повторный post | `{error: "Чек уже проведён."}` | toast |
|
||||
| Serialization failure 40001 (Sprint 23 bug-003) | `{error: "Конфликт параллельных операций. Попробуйте ещё раз.", retryable: true}` | **auto-retry один раз**, при повторе — toast |
|
||||
| Дубликат preset name | `{error: "Пресет с таким именем уже существует..."}` | подсветить input name |
|
||||
| In-flight org-export ≥3 | `{error: "Уже в очереди 3+ экспорта. Подождите..."}` | toast |
|
||||
| Удаление непустой группы товаров | `{error: "Нельзя удалить группу, содержащую товары/подгруппы."}` | toast |
|
||||
|
||||
### 413 — Payload Too Large
|
||||
|
||||
| Когда | Что показать |
|
||||
|---|---|
|
||||
| Body > nginx limit (10 MB по default) | «Файл слишком большой. Лимит: 10 МБ.» |
|
||||
|
||||
### 429 — Too Many Requests
|
||||
|
||||
| Когда | Тело | Что показать |
|
||||
|---|---|---|
|
||||
| Rate-limit на signup (3/h IP) | пусто или `Retry-After` header | «Слишком много попыток. Попробуйте через час.» |
|
||||
| Rate-limit на forgot-password (3/h email + 10/h IP) | то же | то же |
|
||||
| Rate-limit на feedback (5/час) | то же | то же |
|
||||
| IP-limit (60/мин общий) | то же | «Слишком много запросов с вашего IP.» |
|
||||
|
||||
### 431 — Request Header Fields Too Large
|
||||
|
||||
| Когда | Что показать |
|
||||
|---|---|
|
||||
| Слишком большие/много HTTP-headers | (нечем фиксить с UI; нечасто) |
|
||||
|
||||
### 500 — Internal Server Error
|
||||
|
||||
После Sprint 23 — **очень редко**. Если встречается:
|
||||
- Все NUL-byte 500 → теперь 400 (bug-001).
|
||||
- Все serialization 40001 → теперь 409 (bug-003).
|
||||
- Все остальные uncaught exceptions → Serilog лог + `correlation-id` header.
|
||||
|
||||
Что показать пользователю: «Произошла ошибка. Попробуйте ещё раз
|
||||
или сообщите администратору. Код: {x-correlation-id}». Этот correlation
|
||||
id находится в `x-correlation-id` response-header — записываем в audit.
|
||||
|
||||
### 501 — Not Implemented
|
||||
|
||||
| Когда | Тело | Что показать |
|
||||
|---|---|---|
|
||||
| SSO callback flow (Sprint 20 scaffold) | `{status: "scaffolded", message, email, next}` | «SSO ещё не настроено полностью» |
|
||||
|
||||
### 503 — Service Unavailable
|
||||
|
||||
| Когда | Тело | Что показать |
|
||||
|---|---|---|
|
||||
| SSO провайдер не сконфигурирован | `{error: "SSO для X не настроено.", hint: "..."}` | скрыть кнопку SSO |
|
||||
| (резерв на maintenance window) | пусто | «Сервис недоступен» |
|
||||
|
||||
## humanizeError на фронте
|
||||
|
||||
`src/lib/api.ts → humanizeError(err)`:
|
||||
|
||||
```typescript
|
||||
export function humanizeError(err: AxiosError): string {
|
||||
const data = err.response?.data as any
|
||||
// 1. Single-error (наш стандарт)
|
||||
if (data?.error) return data.error
|
||||
// 2. ValidationProblemDetails
|
||||
if (data?.errors) {
|
||||
const first = Object.values(data.errors).flat()[0]
|
||||
return first ?? 'Ошибка валидации'
|
||||
}
|
||||
// 3. По статусу
|
||||
switch (err.response?.status) {
|
||||
case 401: return 'Сессия истекла. Войдите снова.'
|
||||
case 403: return 'Нет прав на это действие.'
|
||||
case 404: return 'Запись не найдена.'
|
||||
case 409: return 'Конфликт версий. Перезагрузите страницу.'
|
||||
case 413: return 'Файл слишком большой.'
|
||||
case 429: return 'Слишком много запросов. Подождите немного.'
|
||||
case 500: return `Ошибка сервера. Код: ${err.response.headers['x-correlation-id'] ?? 'unknown'}`
|
||||
case 503: return 'Сервис временно недоступен.'
|
||||
}
|
||||
return err.message ?? 'Неизвестная ошибка'
|
||||
}
|
||||
```
|
||||
|
||||
## Retry-policy
|
||||
|
||||
| Код | Retry? | Условие |
|
||||
|---|---|---|
|
||||
| 401 | Один раз — после refresh-token | Если refresh тоже 401 → logout |
|
||||
| 409 c `retryable: true` | Один авто-retry с задержкой 500ms | Sprint 23 фикс — серверная сторона уже retry'ит до 5 раз, клиентский — дополнительный safety net |
|
||||
| 429 | Через `Retry-After` секунд (если есть) | Не более 3 попыток |
|
||||
| 500 | НЕТ авто-retry | Пользователь сам решает |
|
||||
| 503 | Через 5 секунд | До 2 попыток |
|
||||
|
||||
Без auto-retry: 400, 403, 404, 413, 501.
|
||||
259
docs/glossary.md
Normal file
259
docs/glossary.md
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
# Глоссарий food-market
|
||||
|
||||
Доменные термины, которые используются в коде, документации и общении
|
||||
с пользователями. Один термин — одно определение. Ссылки на код через
|
||||
`file:line` или namespace.path.
|
||||
|
||||
## Базовые сущности
|
||||
|
||||
### Organization (Организация, tenant)
|
||||
**Корневая сущность мульти-tenancy.** Один процесс API обслуживает много
|
||||
организаций; каждая видит только свои данные через query-filter по
|
||||
`OrganizationId`. Не tenant-scoped сама по себе (отношение «один-ко-многим»
|
||||
с TenantEntity).
|
||||
Code: `foodmarket.Domain.Organizations.Organization` (`src/food-market.domain/Organizations/Organization.cs`).
|
||||
См. [MULTI-TENANCY.md](MULTI-TENANCY.md).
|
||||
|
||||
### TenantEntity / ITenantEntity
|
||||
Базовый класс/интерфейс для всех domain-сущностей с `OrganizationId`.
|
||||
`AppDbContext` автоматически применяет query-filter по reflection.
|
||||
Code: `foodmarket.Domain.Common.TenantEntity` + `ITenantEntity`.
|
||||
|
||||
### IOptionalTenantEntity
|
||||
Двухуровневые справочники: либо системная запись (OrganizationId=null,
|
||||
видна всем, мутирует только SuperAdmin), либо tenant'овская.
|
||||
Пример: `UnitOfMeasure`, `ProductGroup` — есть глобальные «штука», есть
|
||||
кастомные.
|
||||
|
||||
### User
|
||||
Учётная запись для логина (ASP.NET Identity). НЕ привязан к одной org —
|
||||
один email может работать в нескольких организациях через Employee.
|
||||
Code: `foodmarket.Domain.Identity.User`.
|
||||
|
||||
### Employee (Сотрудник)
|
||||
Запись о работнике конкретной org. Может иметь User (для логина) или
|
||||
быть «без аккаунта» (только в чеках/документах). Связан с EmployeeRole.
|
||||
Code: `foodmarket.Domain.Organizations.Employee`.
|
||||
|
||||
### Owner / AccountOwnerUserId
|
||||
Первый пользователь, создавший org через signup. Хранится в
|
||||
`Organization.AccountOwnerUserId`. Не удаляется (кроме как через
|
||||
SuperAdmin reassign).
|
||||
|
||||
### Role / EmployeeRole / RolePermissions
|
||||
- **Identity Role** (ASP.NET) — системная: `SuperAdmin`, `Admin`, `Cashier`,
|
||||
`Storekeeper`, `Manager`.
|
||||
- **EmployeeRole** — per-org кастомная роль (например, «Старший кассир»),
|
||||
привязана к сотруднику. Имеет `RolePermissions` (флаги типа
|
||||
`ProductsEdit`, `RetailSalesOperate`).
|
||||
- **Permission** — атрибут `[RequiresPermission("Name")]` на endpoint'е.
|
||||
Проверяет `RolePermissions` сотрудника текущего юзера.
|
||||
|
||||
### Store (Склад)
|
||||
Физическое место хранения остатков. У org может быть несколько; первый
|
||||
после signup — «MAIN» store.
|
||||
Code: `foodmarket.Domain.Organizations.Store`.
|
||||
|
||||
### RetailPoint (Касса / торговая точка)
|
||||
Привязана к Store, к ней привязывается RetailSale. Может иметь фискальные
|
||||
поля (FiscalSerial, FiscalRegNumber).
|
||||
Code: `foodmarket.Domain.Organizations.RetailPoint`.
|
||||
|
||||
## Каталог
|
||||
|
||||
### Product (Товар)
|
||||
Единица каталога. Имеет несколько Prices (по типам), Barcodes, Images,
|
||||
принадлежит ProductGroup. Поля Sprint 19: `IsArchived`, `IsAvailableForSale`.
|
||||
Code: `foodmarket.Domain.Catalog.Product`.
|
||||
|
||||
### ProductGroup (Группа товаров)
|
||||
Иерархическая (через `ParentId` + `Path`). Корень — «Все товары».
|
||||
Может быть системной (OrganizationId=null) или per-org.
|
||||
Code: `foodmarket.Domain.Catalog.ProductGroup`.
|
||||
|
||||
### ProductPrice (Цена)
|
||||
Один товар × один PriceType = одна цена. Тип может быть «системным»
|
||||
(IsSystem — основная розничная) или «обязательным» (IsRequired — без неё
|
||||
нельзя сохранить товар).
|
||||
Code: `foodmarket.Domain.Catalog.ProductPrice`.
|
||||
|
||||
### PriceType (Тип цены)
|
||||
Розничная / Закупочная / Базовая / Себестоимость и т.д. Per-org. Sprint 1.
|
||||
Code: `foodmarket.Domain.Catalog.PriceType`.
|
||||
|
||||
### ProductBarcode (Штрихкод)
|
||||
Уникальный (составной UNIQUE: Code + Organization). Один товар может
|
||||
иметь несколько штрихкодов; один из них — `IsPrimary` (показывается на
|
||||
этикетке).
|
||||
Code: `foodmarket.Domain.Catalog.ProductBarcode`.
|
||||
|
||||
### UnitOfMeasure (Единица измерения)
|
||||
шт / кг / л / м / упак. Системные (OrganizationId=null) + org-кастомные.
|
||||
`OrgUnitOfMeasure` — таблица per-org enable/disable.
|
||||
Code: `foodmarket.Domain.Catalog.UnitOfMeasure`.
|
||||
|
||||
### Counterparty (Контрагент)
|
||||
Поставщик (Supplier) / Покупатель-юрлицо (LegalEntity) / Покупатель-физлицо
|
||||
(Individual). Имеет БИН/ИИН, банковские реквизиты, контакты.
|
||||
Code: `foodmarket.Domain.Catalog.Counterparty`.
|
||||
|
||||
## Остатки и движения
|
||||
|
||||
### Stock (Остаток)
|
||||
Кеш `SUM(StockMovement.Quantity)` для пары `(Store, Product)`.
|
||||
Поддерживается транзакционно в каждом posting'е документа.
|
||||
Code: `foodmarket.Domain.Inventory.Stock`.
|
||||
|
||||
### StockMovement (Движение остатка)
|
||||
Имматериальная запись об изменении остатка. Source: документ
|
||||
(Supply.Post / RetailSale.Post / Enter.Post / Loss.Post / Transfer.Post /
|
||||
Inventory.Post / SupplierReturn.Post / CustomerReturn.Post).
|
||||
**Инвариант**: `Stock.Quantity ≡ Σ StockMovement.Quantity` для каждой
|
||||
пары (Store, Product). Проверяется property-test'ом (Sprint 15).
|
||||
Code: `foodmarket.Domain.Inventory.StockMovement`.
|
||||
|
||||
## Документы (Documents)
|
||||
|
||||
Все имеют поля: `Number`, `Date`, `Status` (Draft/Posted), `PostedAt`.
|
||||
Имеют `IVersionedEntity` для `xmin` concurrency check.
|
||||
|
||||
### Supply (Приёмка)
|
||||
От поставщика. Увеличивает остаток + пересчитывает скользящую
|
||||
себестоимость (Product.Cost).
|
||||
Code: `foodmarket.Domain.Purchases.Supply`.
|
||||
|
||||
### Enter (Оприходование)
|
||||
Внутреннее. Увеличивает остаток без поставщика. Для коррекций инвентаризации.
|
||||
Code: `foodmarket.Domain.Inventory.Enter`.
|
||||
|
||||
### Loss (Списание)
|
||||
Уменьшает остаток. Причина: порча, кража, тестовое использование.
|
||||
Code: `foodmarket.Domain.Inventory.Loss`.
|
||||
|
||||
### Transfer (Перемещение)
|
||||
Между складами. Уменьшает на исходном, увеличивает на целевом.
|
||||
Code: `foodmarket.Domain.Inventory.Transfer`.
|
||||
|
||||
### Inventory (Инвентаризация)
|
||||
Списки фактических остатков. Расхождение → автоматические Enter/Loss
|
||||
строки при post.
|
||||
Code: `foodmarket.Domain.Inventory.InventoryDoc` (имя класса не Inventory из-за конфликта с namespace).
|
||||
|
||||
### RetailSale (Розничный чек)
|
||||
Продажа через POS / админку. После Post → уменьшает остаток, пишет ОФД
|
||||
снапшот (FiscalNumber etc., Sprint 11), уведомляет SignalR.
|
||||
Code: `foodmarket.Domain.Sales.RetailSale`.
|
||||
|
||||
### Demand (Оптовая отгрузка)
|
||||
Продажа юрлицу. Аналогично RetailSale, но с накладной (печатной формой).
|
||||
Code: `foodmarket.Domain.Sales.Demand`.
|
||||
|
||||
### SupplierReturn (Возврат поставщику)
|
||||
Sprint 5. Уменьшает остаток + возвращает деньги поставщику.
|
||||
Code: `foodmarket.Domain.Purchases.SupplierReturn`.
|
||||
|
||||
### CustomerReturn / RetailSale.IsReturn=true
|
||||
Возврат от покупателя. Реализован через флаг `IsReturn` на RetailSale +
|
||||
ReferenceSaleId. Восстанавливает остаток.
|
||||
|
||||
## Деньги
|
||||
|
||||
### Cost (Себестоимость)
|
||||
Скользящее среднее `(qty_old × cost_old + qty_in × price_in) / (qty_old + qty_in)`.
|
||||
Пересчитывается на каждой проведённой Supply. `Decimal(18,4)`.
|
||||
|
||||
### ReferencePrice (Эталонная цена закупа)
|
||||
Опциональная. Заполняется автоматически unit-price'ом первой Supply;
|
||||
после 30 дней без новых Supply → Hangfire-job переписывает на Cost.
|
||||
|
||||
### VAT (НДС)
|
||||
- `Product.Vat` (default из `Country.VatRate`, в РК — 12%).
|
||||
- `Product.VatEnabled` — управляет видимостью поля на UI.
|
||||
- На документах: `VatMode` (включается «в том числе» / «сверху»).
|
||||
|
||||
### AllowFractionalPrices (Дробные цены)
|
||||
Org-настройка. Если false → все цены округляются до целых при сохранении.
|
||||
Sprint 23 bug-004: round-then-validate чтобы избежать «0 цена прошла
|
||||
required-check».
|
||||
|
||||
## Доступ и безопасность
|
||||
|
||||
### Tenant context
|
||||
`ITenantContext` (resolved per request) выдаёт `OrganizationId` из JWT
|
||||
claim `org_id`. NULL для unauthenticated / SuperAdmin-без-override.
|
||||
|
||||
### SuperAdmin
|
||||
Системная роль. Видит все organizations + может «открыть как…» через
|
||||
`X-Org-Override` header (включает Admin claim для этой org'и).
|
||||
Все действия SuperAdmin'a в override-режиме пишутся в `super_admin_audit_log`.
|
||||
|
||||
### OrgAuditLog
|
||||
Per-tenant журнал каждой mutate-операции (CREATE/UPDATE/DELETE на
|
||||
любую TenantEntity). Пишется автоматически через `OrgAuditInterceptor`
|
||||
на SaveChanges.
|
||||
|
||||
### Permission
|
||||
Атрибут `[RequiresPermission("ProductsEdit")]` на endpoint'е. Проверяет
|
||||
флаг `RolePermissions` сотрудника текущего юзера. Если у юзера нет
|
||||
Employee в этой org — 403.
|
||||
|
||||
## Фоновые операции
|
||||
|
||||
### Hangfire job
|
||||
.NET background job framework. `recurring-job` (по cron) и
|
||||
`background-job` (одноразовый). Хранятся в схеме `hangfire` той же БД.
|
||||
|
||||
### advisory lock (Sprint 18)
|
||||
PostgreSQL `pg_advisory_xact_lock(int, int)` — кооперативная блокировка
|
||||
per-(org, doctype). Используется для сериализации генерации номера
|
||||
документа.
|
||||
|
||||
### Serializable transaction
|
||||
PostgreSQL Isolation Level. Используется в posting'ах документов
|
||||
(`RetailSale.Post`, `Supply.Post`, etc.) для защиты от race на
|
||||
остатках. На конфликте → 40001, теперь мапится в 409 (Sprint 23).
|
||||
|
||||
## Внешние интеграции
|
||||
|
||||
### ОФД (OFD)
|
||||
Оператор Фискальных Данных. В РК: Webkassa, Kassa24, ОФД-Соло.
|
||||
RetailSale.Post после успеха отправляет фискальный документ → получает
|
||||
`FiscalNumber`, `FiscalQrCode`. Sprint 11 scaffolding.
|
||||
|
||||
### МойСклад
|
||||
Сторонняя SaaS-система учёта. Импорт товаров/контрагентов/остатков по
|
||||
OAuth-token (per-org в `Organization.MoySkladToken`).
|
||||
|
||||
### POS (касса)
|
||||
WPF-приложение под Windows 10+. Локальный SQLite-буфер, синк через
|
||||
`/api/pos/v1/*` с idempotency-ключом (см. `pos_batch_acks`).
|
||||
|
||||
### Telegram bot
|
||||
Один platform-bot (token в env). Owner'ы org'и привязывают свой chat-id
|
||||
(`Organization.OwnerTelegramChatId`) для получения daily-сводки.
|
||||
|
||||
## Тестирование
|
||||
|
||||
### Stage
|
||||
`https://test.admin.food-market.kz`. Контейнеры на prod-vm
|
||||
`192.168.1.190`, deploy через `~/deploy-stage.sh`.
|
||||
|
||||
### Smoke / Regression / Verify
|
||||
- **Smoke** — быстрый sanity-check (5 шагов signup → login → bootstrap).
|
||||
- **Regression** — полный e2e через Playwright (44 spec'a в Sprint 23).
|
||||
- **Verify** — спринт-специфичные post-feature тесты.
|
||||
|
||||
## Сокращения
|
||||
|
||||
| Сокр | Что |
|
||||
|---|---|
|
||||
| **AT** | Access Token (JWT, TTL 1h) |
|
||||
| **RT** | Refresh Token (для получения нового AT) |
|
||||
| **PoS** | Point of Sale (касса) |
|
||||
| **ОФД** | Оператор Фискальных Данных |
|
||||
| **БИН** | 12-цифровой номер юрлица в РК |
|
||||
| **ИИН** | 12-цифровой номер физлица в РК |
|
||||
| **RPO** | Recovery Point Objective (макс. потеря данных при backup-restore) |
|
||||
| **RTO** | Recovery Time Objective (время восстановления) |
|
||||
| **CSP** | Content Security Policy (HTTP-header) |
|
||||
| **SA** | SuperAdmin |
|
||||
|
|
@ -14,11 +14,11 @@
|
|||
|
||||
## TL;DR — что работает, что нет
|
||||
|
||||
| Операция | Здоровый сценарий | Предел до деградации | Узкое место |
|
||||
|---|---|---|---|
|
||||
| **GET /api/reports/sales** (1500 чеков в орге) | p95 50-115ms до 5 VU | После 5 VU непредсказуемо (см. ниже) | PG aggregation / connection pool |
|
||||
| **POST /api/auth/signup** | p95 446ms при 50 RPM | 100 RPM с одного IP → 39% 429 | IP rate-limit (60/мин, by design) |
|
||||
| **POST /api/sales/retail + /post** (sequential) | p95 71ms, 17 sales/sec | VU > 1 на одном tenant'е → unique-violation race | `GenerateNumberAsync` race condition |
|
||||
| Операция | Здоровый сценарий | Предел до деградации | Узкое место | Статус |
|
||||
|---|---|---|---|---|
|
||||
| **GET /api/reports/sales** (1500 чеков в орге) | p95 50-115ms до 5 VU | После 5 VU непредсказуемо | PG aggregation / connection pool | как есть |
|
||||
| **POST /api/auth/signup** | p95 446ms при 50 RPM | 100 RPM с одного IP → 39% 429 | IP rate-limit (60/мин, by design) | как есть |
|
||||
| **POST /api/sales/retail + /post** (sequential) | p95 71ms, 17 sales/sec | VU > 1: race на номере (23505) и serialization conflict (40001) | `GenerateNumberAsync` race + Serializable | ✅ Sprint 18: advisory lock убил 23505. Sprint 23: 40001 теперь корректные 409 (было 500). |
|
||||
|
||||
## Прогон 1: signup-burst
|
||||
|
||||
|
|
@ -142,14 +142,14 @@ Tenant с **1500 проведённых чеков, 5535 stock_movements, 200 т
|
|||
|
||||
## Сводка: что нужно поправить
|
||||
|
||||
| Приоритет | Что | Где |
|
||||
|---|---|---|
|
||||
| 🔴 P0 | Race в `GenerateNumberAsync` | `RetailSalesController.cs:957` |
|
||||
| 🟡 P1 | Тот же подход в `SuppliesController/DemandsController/...` | Везде где есть `GenerateNumberAsync` |
|
||||
| 🟡 P1 | `SaveOrFkErrorAsync` не ловит 23505 (unique violation) | `RetailSalesController.cs:418` |
|
||||
| 🟢 P2 | Tune autovacuum для `stock_movements` | PG config / `ALTER TABLE` |
|
||||
| 🟢 P2 | Прометей-алерт на p95 отчёта | observability |
|
||||
| 🟢 P2 | k6 для POS-синка с idempotency | `tests/load/pos-sync.js` |
|
||||
| Приоритет | Что | Где | Статус |
|
||||
|---|---|---|---|
|
||||
| 🔴 P0 | Race в `GenerateNumberAsync` | `RetailSalesController.cs` | ✅ Зафиксен в Sprint 18 через PostgreSQL advisory lock (`DocumentNumberRetry.WithOrgAdvisoryLockAsync` per (orgHash, docTypeHash)). Воспроизводится: 23505 ошибки 53% → 0. |
|
||||
| 🟡 P1 | Тот же подход в `SuppliesController/DemandsController/...` | Везде где есть `GenerateNumberAsync` | ⚠️ Helper `DocumentNumberRetry` готов, но к Supplies/Demands ещё не применён. TODO для будущего спринта. |
|
||||
| 🟡 P1 | 40001 Serializable conflict при concurrent /post → 500 | `RetailSalesController.Post` | ✅ Зафиксен в Sprint 23: `SerializationConflictMiddleware` мапит 40001 → 409 + `SerializableRetry` helper (exp backoff) применён к `RetailSale.PostCoreAsync`. После: 20 параллельных продаж → 0 × 500, 6 ok + 14 × 409, stock invariant сохраняется. |
|
||||
| 🟢 P2 | Tune autovacuum для `stock_movements` | PG config / `ALTER TABLE` | ✅ Sprint 20: `DatabaseMaintenanceJobs.VacuumTopTablesAsync` weekly воскр 04:00 UTC. |
|
||||
| 🟢 P2 | Прометей-алерт на p95 отчёта | observability | ⚠️ Sprint 20 добавил `~/nightly-perf-check.sh` (sliding baseline + Telegram). Реальные Prometheus alert-rules — не настроены. |
|
||||
| 🟢 P2 | k6 для POS-синка с idempotency | `tests/load/pos-sync.js` | ❌ Не реализовано. |
|
||||
|
||||
## Воспроизведение
|
||||
|
||||
|
|
|
|||
|
|
@ -23,6 +23,22 @@ chmod 600 deploy/.env # ограничить доступ
|
|||
| `Cors__AllowedOrigins__N` | — | CORS-origins | API | переопределяет `appsettings.json` |
|
||||
| `RateLimiting__*` | — | антибрутфорс лимиты | API | дефолты 5/мин, 20/час |
|
||||
| `MoySklad__BaseUrl` | — | база API МойСклад | API | дефолт боевой `api.moysklad.ru` |
|
||||
| `Telegram__BotToken` | — | токен Telegram-бота для alert'ов и owner-сводки | `OwnerDailySummaryJob`, `DiskMonitoringJob` | bot @BotFather |
|
||||
| `Telegram__BotUsername` | — | username бота (без @) для deep-link'ов в notify | `TelegramBindingController` | у бота в Telegram |
|
||||
| `Authentication__Google__ClientId` / `__ClientSecret` | — | OAuth Google SSO (Sprint 20 scaffold) | API `ExternalAuthController` | см. `docs/sso.md` |
|
||||
| `Authentication__Microsoft__ClientId` / `__ClientSecret` | — | OAuth Microsoft SSO (Sprint 20 scaffold) | API `ExternalAuthController` | см. `docs/sso.md` |
|
||||
| `Monitoring__DiskPaths` | — | CSV mount-paths для disk-monitor (default `/opt,/var/lib/docker`) | `DiskMonitoringJob` (Sprint 20) | список mount'ов хоста |
|
||||
| `Monitoring__DiskMinFreeBytes` | — | порог alert'a (default 1 GB) | `DiskMonitoringJob` | в байтах |
|
||||
| `Monitoring__DiskAlertCooldownHours` | — | антиспам для disk-alert (default 6h) | `DiskMonitoringJob` | в часах |
|
||||
| `Monitoring__SuperAdminTelegramChatIds` | — | CSV chat-id'ы для disk/perf alert'ов | `DiskMonitoringJob`, `nightly-perf-check.sh` | chat-id юзера |
|
||||
| `Cleanup__DraftDays` / `__OrgAuditLogDays` / `__RevokedRefreshTokenDays` | — | retention для cleanup-job'ов (default 30 / 90 / 7) | `HousekeepingJobs` (Sprint 20) | в днях |
|
||||
| `Hangfire__Retention__StockMovementDays` / `__AuditLogDays` | — | retention для prune'ов | `HousekeepingJobs` | в днях |
|
||||
| `Hangfire__Cron__*` | — | переопределение cron-расписания jobs | `HangfireJobsConfigurator` | стандартный 5-полевой cron |
|
||||
| `Maintenance__VacuumTopN` | — | сколько таблиц VACUUM ANALYZE еженедельно (default 5) | `DatabaseMaintenanceJobs` (Sprint 20) | int |
|
||||
| `App__PublicBaseUrl` | — | публичный URL админки (для email-link'ов на GDPR-export) | `OrgExportJob` (Sprint 22) | напр. `https://admin.food-market.kz` |
|
||||
| `Storage__Type` | — | `local` (default, `/uploads` volume) или `minio` | `StorageBootstrap` | строка |
|
||||
| `Storage__Minio__Endpoint` / `__AccessKey` / `__SecretKey` / `__Bucket` | — | конфиг MinIO/S3 если Type=minio | `MinioObjectStorage` | у провайдера S3 |
|
||||
| `PUBLIC_GA_ID` / `PUBLIC_YM_ID` | — | Google Analytics 4 / Yandex.Metrika ID на marketing-сайте (Sprint 20) | Astro `BaseLayout.astro` | см. `docs/analytics.md` |
|
||||
|
||||
> `__` (двойное подчёркивание) — разделитель секций конфигурации .NET
|
||||
> (`OpenIddict__Issuer` ≡ `OpenIddict:Issuer`).
|
||||
|
|
|
|||
26
docs/sprint24-progress.md
Normal file
26
docs/sprint24-progress.md
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Sprint 24 — docs cross-check + tests gap fill
|
||||
|
||||
Цель: после 23 спринтов docs/ содержит 50+ md файлов. Часть точно
|
||||
устарела. Прогнать на актуальность + заполнить пробелы в integration
|
||||
тестах.
|
||||
|
||||
Старт: 2026-06-08. Исполнитель: Claude Opus 4.7.
|
||||
|
||||
## Чек-лист
|
||||
|
||||
- [ ] **1. Docs vs code cross-check** — пройти каждый md, проверить
|
||||
endpoints/классы/переменные. Устаревшее обновить, TODO явные.
|
||||
- [ ] **2. Auto-generated endpoint reference** — `docs/api-reference.md`
|
||||
через сканер `[ApiController]` + Hangfire weekly.
|
||||
- [ ] **3. Coverage gap analysis** — coverlet → классы <50% → +тесты,
|
||||
lcov-отчёт до/после.
|
||||
- [ ] **4. Contract tests stage vs prod** — `/swagger.json` diff.
|
||||
- [ ] **5. Error code catalog** — `docs/error-codes.md`.
|
||||
- [ ] **6. Glossary** — `docs/glossary.md`.
|
||||
- [ ] **7. Onboarding pack** — `docs/ONBOARDING.md`.
|
||||
- [ ] **8. README.md update** — badges + 5-min quick start.
|
||||
|
||||
## Журнал
|
||||
|
||||
### 2026-06-08 старт
|
||||
Sprint 23 закрыт (4 bugs found, 4 fixed). Поехали по docs.
|
||||
153
src/food-market.api/Background/ApiReferenceDocsJob.cs
Normal file
153
src/food-market.api/Background/ApiReferenceDocsJob.cs
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
using System.Text;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace foodmarket.Api.Background;
|
||||
|
||||
/// <summary>Sprint 24: weekly-job для генерации `docs/api-reference.md`.
|
||||
///
|
||||
/// Полноценный Roslyn-анализатор тянул бы Microsoft.CodeAnalysis (1+ МБ
|
||||
/// в runtime image), что overkill для weekly-job'a. Здесь — regex-сканер
|
||||
/// по C#-файлам контроллеров: ищет `[Route(...)]`, `[Http*(...)]`,
|
||||
/// `[RequiresPermission(...)]`, имена методов + DocComment-summary.
|
||||
///
|
||||
/// Источник правды — реально скомпилированный API (доступен Swagger через
|
||||
/// `/swagger/v1/swagger.json`); этот reference — human-readable summary.
|
||||
/// Для контракт-тестов используется swagger.json напрямую.</summary>
|
||||
public class ApiReferenceDocsJob
|
||||
{
|
||||
private readonly IWebHostEnvironment _env;
|
||||
private readonly ILogger<ApiReferenceDocsJob> _log;
|
||||
|
||||
public ApiReferenceDocsJob(IWebHostEnvironment env, ILogger<ApiReferenceDocsJob> log)
|
||||
{
|
||||
_env = env;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
/// <summary>Сканит каталог Controllers/ в content-root API,
|
||||
/// извлекает endpoint metadata, генерирует markdown в указанный
|
||||
/// outputPath. Возвращает количество обнаруженных endpoint'ов.</summary>
|
||||
public Task<int> GenerateAsync(CancellationToken ct = default)
|
||||
{
|
||||
var root = _env.ContentRootPath;
|
||||
// На stage/prod исходники не лежат — контейнер только publish dll.
|
||||
// Если нет каталога Controllers/ — записываем заглушку с указанием
|
||||
// что docs генерится только в dev-окружении.
|
||||
var srcDir = Path.Combine(root, "Controllers");
|
||||
var altDir = Path.Combine(root, "..", "src", "food-market.api", "Controllers");
|
||||
|
||||
string scanDir;
|
||||
if (Directory.Exists(srcDir)) scanDir = srcDir;
|
||||
else if (Directory.Exists(altDir)) scanDir = altDir;
|
||||
else
|
||||
{
|
||||
var note = $@"# API endpoint reference
|
||||
|
||||
> ⚠️ Этот файл генерируется из исходников `Controllers/*.cs`.
|
||||
> На stage/prod-контейнере исходников нет — здесь только runtime publish.
|
||||
> Полный reference — в репозитории `docs/api-reference.md`, генерится в dev
|
||||
> через `dotnet run` локально.
|
||||
|
||||
Generated at: {DateTime.UtcNow:O}
|
||||
";
|
||||
var notePath = Path.Combine(root, "api-reference-generated.md");
|
||||
File.WriteAllText(notePath, note);
|
||||
_log.LogInformation("ApiReferenceDocs: no source dir, wrote stub to {Path}", notePath);
|
||||
return Task.FromResult(0);
|
||||
}
|
||||
|
||||
var endpoints = ScanDir(scanDir).ToList();
|
||||
var sb = new StringBuilder();
|
||||
sb.AppendLine("# API endpoint reference");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Сгенерировано автоматически из `Controllers/*.cs`: {DateTime.UtcNow:O}.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine($"Всего endpoint'ов: **{endpoints.Count}**.");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("Полная OpenAPI-спека: `/swagger/v1/swagger.json`. Этот reference — human-readable summary.");
|
||||
sb.AppendLine();
|
||||
|
||||
foreach (var grp in endpoints.GroupBy(e => e.ControllerName).OrderBy(g => g.Key))
|
||||
{
|
||||
sb.AppendLine($"## `{grp.Key}`");
|
||||
if (!string.IsNullOrEmpty(grp.First().BaseRoute))
|
||||
sb.AppendLine($"Base route: `{grp.First().BaseRoute}`");
|
||||
sb.AppendLine();
|
||||
sb.AppendLine("| Method | Route | Permission | Summary |");
|
||||
sb.AppendLine("|---|---|---|---|");
|
||||
foreach (var e in grp.OrderBy(e => e.Route))
|
||||
{
|
||||
var perm = string.IsNullOrEmpty(e.Permission) ? "—" : $"`{e.Permission}`";
|
||||
var sum = string.IsNullOrEmpty(e.Summary) ? "" : e.Summary.Replace("|", "\\|");
|
||||
if (sum.Length > 100) sum = sum[..100] + "…";
|
||||
sb.AppendLine($"| {e.HttpMethod} | `{e.Route}` | {perm} | {sum} |");
|
||||
}
|
||||
sb.AppendLine();
|
||||
}
|
||||
|
||||
var outPath = Path.Combine(root, "api-reference-generated.md");
|
||||
File.WriteAllText(outPath, sb.ToString());
|
||||
_log.LogInformation("ApiReferenceDocs: {Count} endpoints → {Path}", endpoints.Count, outPath);
|
||||
return Task.FromResult(endpoints.Count);
|
||||
}
|
||||
|
||||
private record EndpointInfo(
|
||||
string ControllerName, string BaseRoute, string HttpMethod, string Route,
|
||||
string Permission, string Summary);
|
||||
|
||||
private static IEnumerable<EndpointInfo> ScanDir(string dir)
|
||||
{
|
||||
foreach (var file in Directory.EnumerateFiles(dir, "*.cs", SearchOption.AllDirectories))
|
||||
{
|
||||
var text = File.ReadAllText(file);
|
||||
// Class declaration + base [Route]
|
||||
var classMatch = Regex.Match(text, @"\[Route\(""([^""]+)""\)\][\s\S]{0,500}?class\s+(\w+Controller)");
|
||||
string baseRoute = "", className = "";
|
||||
if (classMatch.Success)
|
||||
{
|
||||
baseRoute = classMatch.Groups[1].Value;
|
||||
className = classMatch.Groups[2].Value;
|
||||
}
|
||||
else
|
||||
{
|
||||
var classOnly = Regex.Match(text, @"class\s+(\w+Controller)");
|
||||
if (!classOnly.Success) continue;
|
||||
className = classOnly.Groups[1].Value;
|
||||
}
|
||||
|
||||
// Endpoints: ищем [HttpX(...)] + опц [RequiresPermission(...)] + следующий метод.
|
||||
// Также берём предшествующий /// <summary>... — для column Summary.
|
||||
var endpointRx = new Regex(
|
||||
@"(?<doc>///[^\n]*(?:\n[^\n]*///[^\n]*)*)?\s*" +
|
||||
@"(?<attrs>(?:\[(?:Http\w+|Authorize|RequiresPermission|AllowAnonymous|Consumes)[^\]]*\]\s*,?\s*)+)" +
|
||||
@"public\s+(?:async\s+)?(?:Task<\w+(?:<[^>]+>)?>?|IActionResult|ActionResult<[^>]+>|void)\s+(?<method>\w+)\s*\(",
|
||||
RegexOptions.Multiline);
|
||||
foreach (Match m in endpointRx.Matches(text))
|
||||
{
|
||||
var attrs = m.Groups["attrs"].Value;
|
||||
var http = Regex.Match(attrs, @"\[Http(\w+)(?:\(""([^""]*)""\))?");
|
||||
if (!http.Success) continue;
|
||||
var httpMethod = http.Groups[1].Value.ToUpperInvariant();
|
||||
var subRoute = http.Groups[2].Success ? http.Groups[2].Value : "";
|
||||
var fullRoute = "/" + string.Join("/", new[] { baseRoute, subRoute }.Where(s => !string.IsNullOrEmpty(s))).Trim('/');
|
||||
|
||||
var permMatch = Regex.Match(attrs, @"\[RequiresPermission\(""([^""]+)""\)\]");
|
||||
var perm = permMatch.Success ? permMatch.Groups[1].Value : "";
|
||||
|
||||
var docRaw = m.Groups["doc"].Value;
|
||||
var summary = "";
|
||||
if (!string.IsNullOrEmpty(docRaw))
|
||||
{
|
||||
var sumMatch = Regex.Match(docRaw, @"<summary>\s*(.*?)\s*</summary>", RegexOptions.Singleline);
|
||||
if (sumMatch.Success)
|
||||
{
|
||||
summary = Regex.Replace(sumMatch.Groups[1].Value, @"\s*///\s*", " ");
|
||||
summary = Regex.Replace(summary, @"\s+", " ").Trim();
|
||||
}
|
||||
}
|
||||
|
||||
yield return new EndpointInfo(className, "/" + baseRoute.Trim('/'), httpMethod, fullRoute, perm, summary);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -87,6 +87,14 @@ public Task StartAsync(CancellationToken ct)
|
|||
cronExpression: cronSchema,
|
||||
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
||||
|
||||
// Sprint 24: weekly API endpoint reference generation.
|
||||
var cronApiRef = _cfg["Hangfire:Cron:ApiReferenceDocs"] ?? "30 5 * * 0"; // Воскресенье 05:30 UTC
|
||||
_jobs.AddOrUpdate<ApiReferenceDocsJob>(
|
||||
recurringJobId: "api-reference-docs",
|
||||
methodCall: j => j.GenerateAsync(CancellationToken.None),
|
||||
cronExpression: cronApiRef,
|
||||
options: new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
||||
|
||||
// Email-уведомления: weekly-summary в понедельник 07:00 UTC,
|
||||
// low-stock каждый день в 08:00 UTC (после weekly чтобы не дублить
|
||||
// если оба совпадают). Cron-ы конфигурируются — на тестовом стенде
|
||||
|
|
|
|||
|
|
@ -413,6 +413,8 @@ [new Microsoft.OpenApi.Models.OpenApiSecurityScheme
|
|||
// Sprint 22: GDPR org export + DB schema docs.
|
||||
builder.Services.AddScoped<foodmarket.Api.Background.OrgExportJob>();
|
||||
builder.Services.AddScoped<foodmarket.Api.Background.DbSchemaDocsJob>();
|
||||
// Sprint 24: API endpoint reference docs (weekly).
|
||||
builder.Services.AddScoped<foodmarket.Api.Background.ApiReferenceDocsJob>();
|
||||
builder.Services.AddScoped<foodmarket.Api.Background.EmailNotificationJobs>();
|
||||
|
||||
// Telegram-бот владельца. Token + username берём из конфига; если token
|
||||
|
|
|
|||
309
tests/food-market.IntegrationTests/Sprint18To23FeaturesTests.cs
Normal file
309
tests/food-market.IntegrationTests/Sprint18To23FeaturesTests.cs
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using foodmarket.IntegrationTests.Support;
|
||||
using Xunit;
|
||||
|
||||
namespace foodmarket.IntegrationTests;
|
||||
|
||||
/// <summary>Sprint 24: интеграция-покрытие фич Sprint 18-23, которых не было
|
||||
/// в коде-base'е до этого. Цель — поднять coverage по новым контроллерам
|
||||
/// (BulkUpdate, UserPresets, OrgExport, ExternalAuth, MoySkladSync,
|
||||
/// audit-log/export, 1C-import) и зафиксировать защиту от регрессии багов
|
||||
/// Sprint 23 (bug-001 NUL byte → 400, bug-003 40001 → 409, bug-004 round
|
||||
/// → required check).
|
||||
///
|
||||
/// Один файл с группой [Fact] чтобы получить компактный AAA-блок на каждую
|
||||
/// фичу. Использует существующий ApiActor + ApiFactory (Testcontainers
|
||||
/// Postgres). Каждый Fact создаёт изолированную org (slug = test-name +
|
||||
/// random) — нет cross-test leakage.</summary>
|
||||
[Collection(ApiCollection.Name)]
|
||||
public class Sprint18To23FeaturesTests
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public Sprint18To23FeaturesTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
// ── Sprint 19: bulk-update ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task BulkUpdate_archive_marks_products_archived()
|
||||
{
|
||||
var a = new ApiActor(_factory.CreateClient());
|
||||
await a.SignupAndLoginAsync($"bulk-arch-{Guid.NewGuid():N}");
|
||||
var refs = await a.LoadRefsAsync();
|
||||
var p1 = await a.CreateProductAsync(refs, $"P1-{Guid.NewGuid():N}", 100m, $"BC1-{Guid.NewGuid():N}");
|
||||
var p2 = await a.CreateProductAsync(refs, $"P2-{Guid.NewGuid():N}", 200m, $"BC2-{Guid.NewGuid():N}");
|
||||
|
||||
using var resp = await a.Http.PostAsJsonAsync("/api/catalog/products/bulk-update", new
|
||||
{
|
||||
ids = new[] { p1, p2 },
|
||||
op = "archive",
|
||||
@params = new { },
|
||||
});
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("affected").GetInt32().Should().Be(2);
|
||||
|
||||
// Оба товара должны иметь IsArchived = true.
|
||||
var read1 = await a.GetJsonAsync($"/api/catalog/products/{p1}");
|
||||
read1.GetProperty("isArchived").GetBoolean().Should().BeTrue();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task BulkUpdate_cross_tenant_returns_affected_zero()
|
||||
{
|
||||
var a = new ApiActor(_factory.CreateClient());
|
||||
var b = new ApiActor(_factory.CreateClient());
|
||||
await a.SignupAndLoginAsync($"bulk-iso-a-{Guid.NewGuid():N}");
|
||||
await b.SignupAndLoginAsync($"bulk-iso-b-{Guid.NewGuid():N}");
|
||||
var refs = await a.LoadRefsAsync();
|
||||
var pA = await a.CreateProductAsync(refs, "A-prod", 100m, $"BCA-{Guid.NewGuid():N}");
|
||||
|
||||
using var resp = await b.Http.PostAsJsonAsync("/api/catalog/products/bulk-update", new
|
||||
{
|
||||
ids = new[] { pA }, op = "archive", @params = new { },
|
||||
});
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("affected").GetInt32().Should().Be(0, "tenant isolation должна отбрасывать чужие id");
|
||||
|
||||
// У A товар не архивирован
|
||||
var read = await a.GetJsonAsync($"/api/catalog/products/{pA}");
|
||||
read.GetProperty("isArchived").GetBoolean().Should().BeFalse();
|
||||
}
|
||||
|
||||
// ── Sprint 19: UserPresets ────────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task UserPresets_per_user_in_org_isolated()
|
||||
{
|
||||
var a = new ApiActor(_factory.CreateClient());
|
||||
await a.SignupAndLoginAsync($"preset-{Guid.NewGuid():N}");
|
||||
|
||||
using var create = await a.Http.PostAsJsonAsync("/api/user/presets", new
|
||||
{
|
||||
pageKey = "products", name = "Test", configJson = "{\"foo\":1}",
|
||||
});
|
||||
create.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var id = (await create.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
|
||||
|
||||
var list = await a.ListAsync("/api/user/presets?pageKey=products");
|
||||
list.Should().ContainSingle(p => p.GetProperty("id").GetString() == id);
|
||||
|
||||
using var del = await a.Http.DeleteAsync($"/api/user/presets/{id}");
|
||||
del.StatusCode.Should().Be(HttpStatusCode.NoContent);
|
||||
}
|
||||
|
||||
// ── Sprint 19: inline-edit price ─────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task PatchPrice_updates_amount_with_rounding()
|
||||
{
|
||||
var a = new ApiActor(_factory.CreateClient());
|
||||
await a.SignupAndLoginAsync($"price-{Guid.NewGuid():N}");
|
||||
var refs = await a.LoadRefsAsync();
|
||||
var pid = await a.CreateProductAsync(refs, "X", 100m, $"PB-{Guid.NewGuid():N}");
|
||||
|
||||
using var patch = await a.Http.PatchAsync($"/api/catalog/products/{pid}/price",
|
||||
JsonContent.Create(new { priceTypeId = refs.PriceTypeId, amount = 555m }));
|
||||
patch.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
|
||||
var read = await a.GetJsonAsync($"/api/catalog/products/{pid}");
|
||||
var amount = read.GetProperty("prices").EnumerateArray()
|
||||
.First(p => p.GetProperty("priceTypeId").GetString() == refs.PriceTypeId)
|
||||
.GetProperty("amount").GetDecimal();
|
||||
amount.Should().Be(555m);
|
||||
}
|
||||
|
||||
// ── Sprint 19: CSV import — transactional ────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ImportCsv_two_rows_creates_two_products()
|
||||
{
|
||||
var a = new ApiActor(_factory.CreateClient());
|
||||
await a.SignupAndLoginAsync($"csv-{Guid.NewGuid():N}");
|
||||
var ts = Guid.NewGuid().ToString("N");
|
||||
using var resp = await a.Http.PostAsJsonAsync("/api/catalog/products/import-csv", new
|
||||
{
|
||||
rows = new[] {
|
||||
new { name = $"CSV-A-{ts}", price = 100m, unitCode = "шт", groupName = $"CSV-grp-{ts}", barcode = $"CSV1-{ts}" },
|
||||
new { name = $"CSV-B-{ts}", price = 200m, unitCode = "шт", groupName = $"CSV-grp-{ts}", barcode = $"CSV2-{ts}" },
|
||||
},
|
||||
autoCreateGroup = true,
|
||||
});
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("created").GetInt32().Should().Be(2);
|
||||
}
|
||||
|
||||
// ── Sprint 22: GDPR org export ────────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task OrgExport_creates_pending_and_lists_self_only()
|
||||
{
|
||||
var a = new ApiActor(_factory.CreateClient());
|
||||
await a.SignupAndLoginAsync($"export-{Guid.NewGuid():N}");
|
||||
|
||||
using var resp = await a.Http.PostAsync("/api/org/export", null);
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.Accepted);
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
var id = body.GetProperty("id").GetString();
|
||||
id.Should().NotBeNullOrEmpty();
|
||||
|
||||
// List видит как минимум этот один.
|
||||
var list = await a.ListAsync("/api/org/export");
|
||||
list.Should().Contain(e => e.GetProperty("id").GetString() == id);
|
||||
}
|
||||
|
||||
// ── Sprint 22: 1C CSV import (auto-detect charset) ───────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task Import1cCsv_with_russian_headers_creates_product()
|
||||
{
|
||||
var a = new ApiActor(_factory.CreateClient());
|
||||
await a.SignupAndLoginAsync($"1c-{Guid.NewGuid():N}");
|
||||
var ts = Guid.NewGuid().ToString("N");
|
||||
var csv = "\"Артикул\";\"Наименование\";\"Единица\";\"Цена\";\"Группа\";\"Штрихкод\"\n" +
|
||||
$"\"ART-{ts}\";\"Молоко-{ts}\";\"шт\";\"450\";\"1С-grp-{ts}\";\"1C-{ts}\"";
|
||||
var content = new ByteArrayContent(Encoding.UTF8.GetBytes(csv));
|
||||
content.Headers.Add("Content-Type", "text/csv; charset=utf-8");
|
||||
|
||||
using var resp = await a.Http.PostAsync("/api/catalog/products/import/1c-csv?autoCreateGroup=true", content);
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("created").GetInt32().Should().Be(1);
|
||||
}
|
||||
|
||||
// ── Sprint 22: audit-log streaming export ────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task AuditLogExport_csv_streams_with_bom()
|
||||
{
|
||||
var a = new ApiActor(_factory.CreateClient());
|
||||
await a.SignupAndLoginAsync($"audit-{Guid.NewGuid():N}");
|
||||
// Create something audit-able.
|
||||
await a.CreateCounterpartyAsync($"audit-cp-{Guid.NewGuid():N}");
|
||||
|
||||
using var resp = await a.Http.PostAsync("/api/admin/audit-log/export?format=csv", null);
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
resp.Content.Headers.ContentType?.MediaType.Should().Be("text/csv");
|
||||
var bytes = await resp.Content.ReadAsByteArrayAsync();
|
||||
bytes.Length.Should().BeGreaterThan(3);
|
||||
// UTF-8 BOM
|
||||
bytes[0].Should().Be(0xEF);
|
||||
bytes[1].Should().Be(0xBB);
|
||||
bytes[2].Should().Be(0xBF);
|
||||
}
|
||||
|
||||
// ── Sprint 22: MoySklad sync-status stub ─────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task MoySkladSyncStatus_returns_configured_false_when_no_token()
|
||||
{
|
||||
var a = new ApiActor(_factory.CreateClient());
|
||||
await a.SignupAndLoginAsync($"ms-{Guid.NewGuid():N}");
|
||||
|
||||
var body = await a.GetJsonAsync("/api/moysklad/sync-status");
|
||||
body.GetProperty("configured").GetBoolean().Should().BeFalse();
|
||||
body.GetProperty("pendingCount").GetInt32().Should().Be(0);
|
||||
}
|
||||
|
||||
// ── Sprint 20: SSO external auth ──────────────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task SsoProviders_returns_both_false_when_unconfigured()
|
||||
{
|
||||
var a = new ApiActor(_factory.CreateClient());
|
||||
await a.SignupAndLoginAsync($"sso-{Guid.NewGuid():N}");
|
||||
|
||||
var body = await a.GetJsonAsync("/api/auth/external/providers");
|
||||
body.GetProperty("google").GetBoolean().Should().BeFalse();
|
||||
body.GetProperty("microsoft").GetBoolean().Should().BeFalse();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SsoChallenge_unconfigured_returns_503_with_hint()
|
||||
{
|
||||
var a = new ApiActor(_factory.CreateClient());
|
||||
await a.SignupAndLoginAsync($"sso2-{Guid.NewGuid():N}");
|
||||
using var resp = await a.Http.GetAsync("/api/auth/external/google");
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.ServiceUnavailable);
|
||||
var body = await resp.Content.ReadFromJsonAsync<JsonElement>();
|
||||
body.GetProperty("error").GetString().Should().Contain("Google");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task SsoChallenge_unknown_provider_returns_400()
|
||||
{
|
||||
var a = new ApiActor(_factory.CreateClient());
|
||||
await a.SignupAndLoginAsync($"sso3-{Guid.NewGuid():N}");
|
||||
using var resp = await a.Http.GetAsync("/api/auth/external/whatever");
|
||||
resp.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
}
|
||||
|
||||
// ── Sprint 23: bug-001 NUL byte → 400 ──────────────────────────────
|
||||
|
||||
[Fact]
|
||||
public async Task ProductCreate_with_null_byte_in_name_returns_400()
|
||||
{
|
||||
var a = new ApiActor(_factory.CreateClient());
|
||||
await a.SignupAndLoginAsync($"nul-{Guid.NewGuid():N}");
|
||||
var refs = await a.LoadRefsAsync();
|
||||
using var resp = await a.Http.PostAsJsonAsync("/api/catalog/products", new
|
||||
{
|
||||
name = "Hello | ||||