food-market/tests/load/sales-report-heavy.js
nns 97e26a65d5 docs(s12): ARCHITECTURE/MULTI-TENANCY/RUNBOOK/DEVELOPER-GUIDE + k6 baseline + stage-verify CI
Документация для следующего разработчика (4 файла, ~1500 строк по
существу), реальный нагрузочный baseline на stage, и автоматический
smoke на каждый push.

Доки:
- docs/ARCHITECTURE.md — карта слоёв, модулей, Program.cs composition
  root, полный поток signup→post с трассировщиком ASP.NET pipeline.
- docs/MULTI-TENANCY.md — ITenantEntity + reflection query-filter,
  stamping в SaveChanges, SuperAdmin override (read-only + edit-mode
  с reason), 8 подводных камней, чеклист «как добавить tenant-сущность».
- docs/RUNBOOK.md — health-чеки, backup/restore с примером, смена SDK,
  disaster-recovery на новый сервер, 6 описанных инцидентов
  (включая docker-compose project name), БД-troubleshooting.
- docs/DEVELOPER-GUIDE.md — локальный setup, гочи integration-тестов,
  полные паттерны (controller с permission + tenant-сущность с
  RowVersion + 5 шагов миграции), валидация, structured-логирование,
  «НЕ делать» список.

k6 baseline:
- tests/load/ — 3 скрипта (signup-burst, retail-sales-parallel,
  sales-report-heavy) + README с инструкциями.
- docs/performance-baseline.md — реальные цифры на stage:
  * signup p95 446ms @ 50 RPM (IP-лимит 60/мин держит);
  * retail-sale sequential — 17/sec, p95 71ms;
  * retail-sale @ VU>1 — 53% failure из-за race в
    GenerateNumberAsync (unique-violation 23505 не ловится в
    SaveOrFkErrorAsync) — P0 для следующего рефакторинга;
  * reports на 1500 чеков — p95 50-114ms до VU=5.

CI:
- .forgejo/workflows/stage-verify.yml — on workflow_run после Docker
  API/Web, wait-for-ready → tests/stage-smoke.sh → Telegram пинг.
- tests/stage-smoke.sh — 7-секундный bash-смок (curl+jq+python3),
  5 этапов: health, signup, token, multi-tenant изоляция (B → 404
  на product A, B → пустой список), полный документ-цикл
  (supplier+supply.post → stock=100 → sale.post → stock=99).
  Локальный прогон против stage — все этапы зелёные.

Build чистый, локальный прогон smoke зелёный. Sprint 12 закрывает
автономно-безопасный цикл — дальше нужен вход от user'а.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 03:19:25 +05:00

79 lines
3.4 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// sales-report-heavy.js — нагрузка чтения отчётов.
//
// Сценарий: один tenant с уже-нагенерированными чеками (1500 через
// YearDemoSeeder или 10000+ через несколько подряд запусков
// retail-sales-parallel.js). 30 секунд VU дёргают
// GET /api/reports/sales и GET /api/reports/abc, мерим p95/p99.
//
// Это «чтение тяжёлого агрегата», самый показательный bench для
// PG-индексов и плана запроса. Если p95 > 2с при 1500 чеках —
// нужен EXPLAIN.
import http from 'k6/http';
import { check } from 'k6';
import { Trend, Rate } from 'k6/metrics';
const BASE_URL = __ENV.BASE_URL || 'http://localhost:5081';
const DURATION_S = Number(__ENV.DURATION_S || 30);
const VUS = Number(__ENV.VUS || 10);
// Email/пароль уже-существующего tenant'а — нужно подсунуть. По дефолту
// — admin@food-market.local (SuperAdmin без своей орги, отчёты будут
// пустыми, но мы мерим только время ответа эндпоинтов).
const EMAIL = __ENV.EMAIL || 'admin@food-market.local';
const PASSWORD = __ENV.PASSWORD || 'Admin12345!';
const salesTrend = new Trend('reports_sales_ms', true);
const abcTrend = new Trend('reports_abc_ms', true);
const dashboardTrend = new Trend('dashboard_top_ms', true);
const non2xx = new Rate('reports_non_2xx');
export const options = {
scenarios: {
reports: {
executor: 'constant-vus',
vus: VUS,
duration: `${DURATION_S}s`,
},
},
thresholds: {
reports_sales_ms: ['p(95)<2500', 'p(99)<5000'],
reports_abc_ms: ['p(95)<3000', 'p(99)<6000'],
reports_non_2xx: ['rate<0.01'],
},
};
export function setup() {
const r = http.post(`${BASE_URL}/connect/token`,
`grant_type=password&username=${encodeURIComponent(EMAIL)}&password=${encodeURIComponent(PASSWORD)}&client_id=food-market-web&scope=openid profile email roles api offline_access`,
{ headers: { 'Content-Type': 'application/x-www-form-urlencoded' } });
if (r.status !== 200) throw new Error(`token failed for ${EMAIL}: ${r.status} ${r.body}`);
return { token: r.json('access_token') };
}
export default function (ctx) {
const auth = { headers: { Authorization: `Bearer ${ctx.token}` }, tags: {} };
// Период «весь год» — стандартный запрос дашборда «за год».
const dateFrom = '2026-01-01';
const dateTo = '2026-12-31';
let t0 = Date.now();
let r = http.get(`${BASE_URL}/api/reports/sales?dateFrom=${dateFrom}&dateTo=${dateTo}&groupBy=day`, auth);
salesTrend.add(Date.now() - t0);
non2xx.add(r.status >= 400);
check(r, { 'sales 2xx': (x) => x.status >= 200 && x.status < 300 });
t0 = Date.now();
r = http.get(`${BASE_URL}/api/reports/abc?dateFrom=${dateFrom}&dateTo=${dateTo}`, auth);
abcTrend.add(Date.now() - t0);
non2xx.add(r.status >= 400);
check(r, { 'abc 2xx': (x) => x.status >= 200 && x.status < 300 });
t0 = Date.now();
r = http.get(`${BASE_URL}/api/dashboard/top-products?limit=5`, auth);
dashboardTrend.add(Date.now() - t0);
non2xx.add(r.status >= 400);
check(r, { 'dashboard 2xx': (x) => x.status >= 200 && x.status < 300 });
}