food-market/tests/load/retail-sales-parallel.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

174 lines
7.9 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.

// retail-sales-parallel.js — нагрузка на проведение чеков.
//
// Сценарий: один tenant создан вне теста (или одним setup), все VU
// проводят чеки параллельно. Цель — увидеть p95/p99 операции
// «создать draft → /post → дождаться NoContent» под нагрузкой.
//
// По умолчанию: 1000 итераций за 5 минут (~3.3 RPS). Подкручивается
// через DURATION_S и TARGET_ITERS env'ами.
//
// ВАЖНО:
// - Перед запуском нужен tenant с продуктом и приёмкой. setup()
// делает всё сам: signup → seed product → post supply на 10 000 шт.
// Один setup на запуск — потом VU работают параллельно с тем же
// токеном. Если итерация не находит остатка (например, тестовый
// tenant закончился) — фейлим check.
// - Запуск против stage заметно дольше из-за сетевого RTT (~30-100мс
// на запрос). Локально на dev-vm обычно в 3-5 раз быстрее.
import http from 'k6/http';
import { check } from 'k6';
import { Trend, Rate, Counter } from 'k6/metrics';
const BASE_URL = __ENV.BASE_URL || 'http://localhost:5081';
const DURATION_S = Number(__ENV.DURATION_S || 300); // 5 минут
const TARGET_ITERS = Number(__ENV.TARGET_ITERS || 1000);
const draftTrend = new Trend('sale_draft_ms', true);
const postTrend = new Trend('sale_post_ms', true);
const totalTrend = new Trend('sale_total_ms', true);
const post4xx = new Rate('post_4xx');
const completed = new Counter('sales_completed');
const VUS = Number(__ENV.VUS || 5);
export const options = {
scenarios: {
retail_sales: {
executor: 'shared-iterations',
iterations: TARGET_ITERS,
vus: VUS, // дефолт 5 параллельных кассиров
maxDuration: `${DURATION_S}s`,
},
},
thresholds: {
// p95 «draft + post» на stage обычно 200-700ms.
sale_total_ms: ['p(95)<3000', 'p(99)<6000'],
// ⚠️ При VU > 5 на одном tenant'е будут конфликты на уникальный
// индекс RetailSale.Number (генератор номера расы) — это
// отдельная задача для оптимизации (см. performance-baseline.md).
post_4xx: ['rate<0.10'],
},
};
// ── setup: один раз для всех VU ────────────────────────────────────────
export function setup() {
const slug = `load-sales-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
const email = `${slug}@example.kz`;
const password = 'Passw0rd!';
// 1. Signup
let r = http.post(`${BASE_URL}/api/auth/signup`, JSON.stringify({
email, password, organizationName: `LoadOrg-${slug}`,
phone: '+77001234567', plan: null,
}), { headers: { 'Content-Type': 'application/json' } });
if (r.status >= 300) throw new Error(`signup failed: ${r.status} ${r.body}`);
// 2. Token
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: ${r.status} ${r.body}`);
const token = r.json('access_token');
const auth = { headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` } };
// 3. Достаём refs (unit/group/store/currency/priceType)
const unitsRes = http.get(`${BASE_URL}/api/catalog/units-of-measure?pageSize=200`, auth);
const unit = unitsRes.json('items').find(u => u.code === '796');
const groupsRes = http.get(`${BASE_URL}/api/catalog/product-groups`, auth);
const group = groupsRes.json('items')[0];
const ptsRes = http.get(`${BASE_URL}/api/catalog/price-types`, auth);
const pt = ptsRes.json('items').find(p => p.isRetail);
const cursRes = http.get(`${BASE_URL}/api/catalog/currencies`, auth);
const cur = cursRes.json('items').find(c => c.code === 'KZT');
const storesRes = http.get(`${BASE_URL}/api/catalog/stores`, auth);
const store = storesRes.json('items').find(s => s.isMain);
const rpsRes = http.get(`${BASE_URL}/api/catalog/retail-points`, auth);
const retailPoint = rpsRes.json('items')[0];
// 4. Создаём продукт
const prodRes = http.post(`${BASE_URL}/api/catalog/products`, JSON.stringify({
name: 'Load test product',
article: `LT-${slug}`,
unitOfMeasureId: unit.id,
vat: 12, vatEnabled: true,
productGroupId: group.id,
packaging: 1,
prices: [{ priceTypeId: pt.id, amount: 100, currencyId: cur.id }],
barcodes: [{ code: `9990000${Math.floor(Math.random()*1e6).toString().padStart(7,'0')}`, type: 1, isPrimary: true }],
}), auth);
if (prodRes.status >= 300) throw new Error(`product failed: ${prodRes.status} ${prodRes.body}`);
const productId = prodRes.json('id');
// 5. Поставщик + приёмка на 10 000 шт.
const supRes = http.post(`${BASE_URL}/api/catalog/counterparties`,
JSON.stringify({ name: 'LoadSupplier', type: 2 }), auth);
const supplierId = supRes.json('id');
const supplyRes = http.post(`${BASE_URL}/api/purchases/supplies`, JSON.stringify({
date: new Date().toISOString(),
supplierId, storeId: store.id, currencyId: cur.id,
lines: [{ productId, quantity: 10000, unitPrice: 50 }],
}), auth);
const supplyId = supplyRes.json('id');
const postSupplyRes = http.post(`${BASE_URL}/api/purchases/supplies/${supplyId}/post`, null, auth);
if (postSupplyRes.status >= 300) throw new Error(`supply post failed: ${postSupplyRes.status}`);
return {
token, productId,
storeId: store.id,
retailPointId: retailPoint.id,
currencyId: cur.id,
};
}
// ── default fn: каждая итерация = один проведённый чек ─────────────────
export default function (ctx) {
const auth = {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${ctx.token}`,
},
};
const t0 = Date.now();
// 1. Создать draft (POST /api/sales/retail)
const draftPayload = JSON.stringify({
date: new Date().toISOString(),
storeId: ctx.storeId,
retailPointId: ctx.retailPointId,
customerId: null,
currencyId: ctx.currencyId,
payment: 0, isReturn: false,
lines: [{
productId: ctx.productId,
quantity: 1, unitPrice: 100, discount: 0, vatPercent: 12,
}],
subtotal: 100, discountTotal: 0, total: 100,
paidCash: 100, paidCard: 0,
notes: 'k6-load',
});
const tDraft0 = Date.now();
const draftRes = http.post(`${BASE_URL}/api/sales/retail`, draftPayload, auth);
draftTrend.add(Date.now() - tDraft0);
const draftOk = check(draftRes, { 'draft 2xx': (r) => r.status >= 200 && r.status < 300 });
if (!draftOk) {
post4xx.add(1);
return;
}
const saleId = draftRes.json('id');
// 2. Провести (POST /api/sales/retail/{id}/post)
const tPost0 = Date.now();
const postRes = http.post(`${BASE_URL}/api/sales/retail/${saleId}/post`, null, auth);
postTrend.add(Date.now() - tPost0);
post4xx.add(postRes.status >= 400);
check(postRes, { 'post 204': (r) => r.status === 204 });
totalTrend.add(Date.now() - t0);
if (postRes.status === 204) completed.add(1);
}