Документация для следующего разработчика (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>
174 lines
7.9 KiB
JavaScript
174 lines
7.9 KiB
JavaScript
// 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);
|
||
}
|