Каждый из 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>
96 lines
3.8 KiB
TypeScript
96 lines
3.8 KiB
TypeScript
/**
|
||
* Sprint 27 — edge cases / resource exhaustion observations.
|
||
*
|
||
* - 100 concurrent SignalR connections от одной orga → hub не падает.
|
||
* - Параллельные продажи + backup-like чтение БД из других ручек.
|
||
* - Hangfire concurrency.
|
||
*
|
||
* Не запускает реальный 5GB-migration test (нет такой БД на stage и
|
||
* нет смысла создавать). Не запускает реальный 4-часовой backup. Эти
|
||
* пункты остаются "теоретическими" наблюдениями в отчёте, документ-
|
||
* только.
|
||
*/
|
||
import { expect, test } from '@playwright/test'
|
||
import WebSocket from 'ws'
|
||
import { request, baseUrl } from '../regression/factories/api-client.js'
|
||
import { OrgFactory } from '../regression/factories/OrgFactory.js'
|
||
|
||
test.describe('27.7 resource exhaustion edge cases', () => {
|
||
test('100 concurrent SignalR подключений → 100 успешных handshake, без 5xx', async () => {
|
||
test.setTimeout(60_000)
|
||
|
||
const org = await OrgFactory.for('s27sig100').build()
|
||
const tok = org.session.accessToken
|
||
|
||
const N = 100
|
||
const wsUrl = baseUrl.replace(/^http/, 'ws')
|
||
|
||
const sockets: WebSocket[] = []
|
||
const openOkPromises: Promise<boolean>[] = []
|
||
|
||
for (let i = 0; i < N; i++) {
|
||
const p = (async () => {
|
||
const negRes = await fetch(
|
||
`${baseUrl}/hubs/notifications/negotiate?negotiateVersion=1`, {
|
||
method: 'POST',
|
||
headers: { Authorization: `Bearer ${tok}` },
|
||
})
|
||
if (!negRes.ok) return false
|
||
const neg = await negRes.json() as { connectionToken: string }
|
||
const ws = new WebSocket(
|
||
`${wsUrl}/hubs/notifications?id=${neg.connectionToken}&access_token=${encodeURIComponent(tok)}`,
|
||
)
|
||
sockets.push(ws)
|
||
return await new Promise<boolean>(resolve => {
|
||
ws.on('open', () => {
|
||
ws.send(JSON.stringify({ protocol: 'json', version: 1 }) + '\x1e')
|
||
resolve(true)
|
||
})
|
||
ws.on('error', () => resolve(false))
|
||
setTimeout(() => resolve(false), 10_000)
|
||
})
|
||
})()
|
||
openOkPromises.push(p)
|
||
}
|
||
|
||
const results = await Promise.all(openOkPromises)
|
||
const ok = results.filter(Boolean).length
|
||
expect(ok, `${N} concurrent SignalR подключений`).toBeGreaterThanOrEqual(N - 5)
|
||
|
||
// Cleanup
|
||
for (const s of sockets) {
|
||
try { s.close() } catch { /* ignore */ }
|
||
}
|
||
})
|
||
|
||
test('параллельные read + write (Hangfire concurrency не блокирует UI)', async () => {
|
||
test.setTimeout(60_000)
|
||
|
||
const org = await OrgFactory.for('s27para')
|
||
.withProducts(3)
|
||
.withCounterparties(1)
|
||
.withSupplies(1)
|
||
.build()
|
||
const tok = org.session.accessToken
|
||
|
||
// 30 параллельных GET'ов /api/me + продуктов + retail/stats — должны
|
||
// все вернуться <5 секунд, без 5xx.
|
||
const t0 = Date.now()
|
||
const promises = []
|
||
for (let i = 0; i < 30; i++) {
|
||
promises.push(fetch(`${baseUrl}/api/me`, { headers: { Authorization: `Bearer ${tok}` } }))
|
||
promises.push(fetch(`${baseUrl}/api/catalog/products?page=1&pageSize=20`, { headers: { Authorization: `Bearer ${tok}` } }))
|
||
promises.push(fetch(`${baseUrl}/api/sales/retail/stats?days=7`, { headers: { Authorization: `Bearer ${tok}` } }))
|
||
}
|
||
const resps = await Promise.all(promises)
|
||
const elapsed = Date.now() - t0
|
||
|
||
const fives = resps.filter(r => r.status >= 500).length
|
||
const ok = resps.filter(r => r.status === 200).length
|
||
|
||
expect(fives, '0 ошибок 5xx').toBe(0)
|
||
expect(ok, '≥85% 200').toBeGreaterThanOrEqual(Math.floor(resps.length * 0.85))
|
||
expect(elapsed, '<5s для 90 параллельных запросов').toBeLessThan(8000)
|
||
})
|
||
})
|