Каждый из 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>
121 lines
4.3 KiB
JavaScript
121 lines
4.3 KiB
JavaScript
// 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,
|
||
})
|
||
}
|