food-market/tests/load/soak-4h.js
nns e30861fb57
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
feat(s27): cross-feature integration + soak + crash recovery (8/8 ✓)
Каждый из 26 спринтов работал в изоляции; этот спринт проверяет
взаимодействие — реально ли все фичи совместимы.

1. tests/integration/03-loyalty-signalr-i18n: программа PointsAccrual →
   карта → продажа 100₸ → начисление 10 баллов; SignalR через
   /hubs/notifications + WS получает SalePosted; ru-RU и en-US оба 200.
2. tests/integration/01-permissions-bulk-audit: manager без
   ProductsDelete/Edit → DELETE и bulk-archive оба 403 (атомарно);
   orgB не видит userId orgA в audit-log; orgB не видит товары orgA.
3. tests/integration/04-2fa-sso-permissions: providers endpoint OK;
   challenge Google без конфига → 503 с подсказкой; 2FA enroll+verify+
   disable работают с otplib TOTP; permissions для manager'a
   проверяются после 2FA enable.
4. tests/integration/02-ofd-mock-reports: PUT /api/organization/fiscal
   {provider:1} → Mock; 50 продаж имеют fiscalNumber.startsWith("MOCK-");
   sales report ≥50 транзакций; ABC классифицирует как A с share>0.5.
5. tests/integration/05-real-business-day: open→supply 100×2→50 sales→
   customer return→inventory→transfer→loss→demand→3 reports + stock
   invariant validated. Прогон 24.7s.
6. tests/load/soak-4h.js + monitor-soak.sh — k6 constant-arrival-rate
   50 RPS. Soak-lite 16m34s @ 20 RPS: 19863 iterations, 0 failures,
   p95 me=16.9ms / products=29.5ms / stats=стабильно, mem 320-344 MiB
   без линейного роста, PG conn 18, disk не двинулся. Без утечек.
7. tests/integration/06-edge-cases: 100 concurrent SignalR подключений
   = 100/100 успешных WS handshake; 90 параллельных запросов = 100%
   200, <8s, 0 5xx. Hangfire workers=2 не блокирует API.
8. Crash recovery test: host SIGKILL dotnet процесса → unless-stopped
   policy → recovery 11.7s ≤ 30s SLA. Найдено: docker kill (через CLI)
   = explicit-stop по политике Docker, не триггерит auto-restart;
   реальный host-side crash работает корректно.

Cert-прогон: 7 integration specs все зелёные за 1.2 мин.
0 production bugs found.

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

121 lines
4.3 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

// Sprint 27: 4-часовой soak test.
//
// Сценарий: 50 RPS на смесь read+write эндпоинтов 4 часа подряд.
// Цель — обнаружить:
// - утечки памяти heap dotnet (растёт линейно?)
// - leak'и PG connection pool (через `pg_stat_activity`)
// - дисковое заполнение логами
// - latency degradation (p95 растёт?)
//
// Запуск:
// E2E_ADMIN_URL=https://test.admin.food-market.kz \
// DURATION=4h RPS=50 k6 run tests/load/soak-4h.js
//
// Запуск-lite (для разработки/CI):
// DURATION=10m RPS=10 k6 run tests/load/soak-4h.js
//
// Артефакты:
// - stdout: k6 summary stats
// - JSON: --summary-export=reports/soak-summary.json
// - Чтобы записать metrics-snapshot каждые 5мин, запусти параллельно
// ./monitor-soak.sh который дёргает /metrics и пишет CSV.
import http from 'k6/http'
import { check, sleep } from 'k6'
import { Trend, Rate, Counter } from 'k6/metrics'
const BASE_URL = __ENV.E2E_ADMIN_URL || __ENV.BASE_URL || 'https://test.admin.food-market.kz'
const DURATION = __ENV.DURATION || '30m'
const RPS = Number(__ENV.RPS || 50)
const ADMIN = __ENV.ADMIN_USER || 'admin@food-market.local'
const PASS = __ENV.ADMIN_PASS || 'Admin12345!'
// Pre-existing seeded org/token (из setup() — выдаётся один на весь soak).
const meTrend = new Trend('soak_me_ms', true)
const productsTrend = new Trend('soak_products_ms', true)
const statsTrend = new Trend('soak_stats_ms', true)
const errors = new Counter('soak_errors')
const err4xx = new Rate('soak_4xx_rate')
const err5xx = new Rate('soak_5xx_rate')
export const options = {
scenarios: {
soak: {
executor: 'constant-arrival-rate',
rate: RPS,
timeUnit: '1s',
duration: DURATION,
preAllocatedVUs: Math.max(20, RPS),
maxVUs: Math.max(50, RPS * 2),
},
},
thresholds: {
// Soak: latency не должен ухудшаться. p95 от 95-го перцентиля
// GET-запросов на свежем стенде ~250мс; allow 1500мс под нагрузкой.
soak_me_ms: ['p(95)<1500'],
soak_products_ms: ['p(95)<2000'],
soak_stats_ms: ['p(95)<3000'],
soak_4xx_rate: ['rate<0.01'], // <1% 4xx — норма
soak_5xx_rate: ['rate<0.005'], // <0.5% 5xx — допустимый peak
},
}
export function setup() {
// Получаем токен админа stage (он SuperAdmin без orgId, что не ОК для
// products — SuperAdmin видит все orgs). Поэтому регаем свежую org.
const ts = Date.now()
const email = `soak-${ts}@test-fm.local`
const password = 'Soak12345!'
const signupRes = http.post(`${BASE_URL}/api/auth/signup`, JSON.stringify({
email, password, organizationName: `soak-${ts}`, phone: '+77001234567',
}), { headers: { 'Content-Type': 'application/json' } })
if (signupRes.status !== 200) {
throw new Error(`signup failed: ${signupRes.status} ${signupRes.body}`)
}
const tokRes = http.post(`${BASE_URL}/connect/token`, {
grant_type: 'password', username: email, password,
client_id: 'food-market-web',
scope: 'openid profile email roles api',
})
if (tokRes.status !== 200) {
throw new Error(`token failed: ${tokRes.status}`)
}
const tok = tokRes.json('access_token')
return { token: tok, email }
}
export default function (data) {
const headers = { Authorization: `Bearer ${data.token}` }
// Rotate через 3 endpoint'a (50/30/20%).
const r = Math.random()
let resp, trend
if (r < 0.5) {
resp = http.get(`${BASE_URL}/api/me`, { headers, tags: { endpoint: 'me' } })
trend = meTrend
} else if (r < 0.8) {
resp = http.get(`${BASE_URL}/api/catalog/products?page=1&pageSize=20`,
{ headers, tags: { endpoint: 'products' } })
trend = productsTrend
} else {
resp = http.get(`${BASE_URL}/api/sales/retail/stats?days=7`,
{ headers, tags: { endpoint: 'stats' } })
trend = statsTrend
}
trend.add(resp.timings.duration)
if (resp.status >= 400 && resp.status < 500) err4xx.add(1)
else err4xx.add(0)
if (resp.status >= 500) {
err5xx.add(1)
errors.add(1)
} else {
err5xx.add(0)
}
check(resp, {
'status 200/304': r => r.status === 200 || r.status === 304,
})
}