food-market/tests/regression/factories/api-client.ts
nns cf760fab10
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(s26): flaky-test detection + observability dashboards (8/8 ✓ 10/10 cert)
После 24 спринтов regress-suite разросся; нестабильность блокирует
доверие. Этот спринт: ловит flaky тесты, добавляет observability
(Grafana + Prometheus alerts + RUNBOOK), сертифицирует 10× cert-прогон.

1. tests/regression/find-flaky.sh — 10× прогон + JSON-агрегатор →
   docs/flaky-tests.md (per-test pass/fail sequence + reproduce).
2. OrgFactory.signupWithRetry теперь honors Retry-After header
   (api-client.ts:ApiError.retryAfterSec). Stage rate-limit поднят:
   RATE_SIGNUP_HOUR=5000, RATE_PER_IP_MIN=5000 (~/food-market-stage/deploy/.env).
3. fullyParallel=true + workers=4 = тесты идут в недетерминированном
   порядке; isolation работает (OrgFactory per-test).
4. workers=4 даёт **2.4× ускорение** (66.6s → 27.7s). Worker-scoped
   фикстура lib/worker-org.ts добавлена как opt-in.
5. deploy/grafana/dashboards/quality-watchdog.json (10 панелей:
   smoke success ratio 7d, incidents, multi-tenant violations,
   current emoji, p95 by endpoint, step failures, RPS, DB p95,
   docs posted, disk free) + dashboards/README.md.
   quality-watchdog.sh пишет Prometheus textfile экспорт в
   ~/.fm-watchdog/textfile/quality_watchdog.prom для node_exporter.
6. deploy/prometheus/alerts.yml — 10 правил, 4 группы (uptime,
   errors, database, quality-watchdog). MultiTenantViolation = P0.
   deploy/prometheus/prometheus.yml — reference config.
7. docs/RUNBOOK.md +178 строк: action per alert (api-down,
   rps-drop, http-errors-spike/growing, doc-posting-errors,
   db-p95-high, disk-free-low, watchdog-red, multi-tenant-violation,
   watchdog-incident). Junior-friendly с конкретными командами.

**Cert-прогон (10× workers=4):** 420/420 passed, 0 flaky, avg 30.1s/run,
total 300.6s (< 5min budget).

Изменения вне репо:
- ~/food-market-stage/deploy/.env — RATE_* limits bumped.
- ~/quality-watchdog.sh — добавлен .prom textfile экспорт.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-08 14:44:19 +05:00

93 lines
3.3 KiB
TypeScript
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.

/**
* Sprint 16: тонкий HTTP-клиент для regression-factories.
*
* Не используем @playwright/test request — он завязан на test-context;
* a factory должна работать как из теста, так и из standalone-скриптов
* (nightly verify, seed-rare-data). Делаем поверх `fetch` (Node 20+ ships
* нативно).
*/
const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'
interface RequestOpts {
method?: 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH'
body?: unknown
/** Bearer-токен, если запрос требует авторизации. */
token?: string
/** Доп. headers. */
headers?: Record<string, string>
/** Не падать при non-2xx. По умолчанию падаем. */
allowError?: boolean
/** Кастомный timeout (ms). По умолчанию 30 секунд. */
timeoutMs?: number
}
export class ApiError extends Error {
constructor(
public status: number,
public bodyText: string,
public url: string,
public method: string,
/** Sprint 26: значение Retry-After если сервер прислал (для 429/503). */
public retryAfterSec?: number,
) {
super(`${method} ${url}${status}: ${bodyText.substring(0, 400)}`)
}
}
/** Универсальный helper. Возвращает распарсенный JSON (или текст для не-JSON). */
export async function request<T = unknown>(path: string, opts: RequestOpts = {}): Promise<T> {
const url = path.startsWith('http') ? path : `${BASE}${path}`
const method = opts.method ?? (opts.body !== undefined ? 'POST' : 'GET')
const headers: Record<string, string> = {
'Accept': 'application/json',
...(opts.headers ?? {}),
}
let body: BodyInit | undefined
if (opts.body !== undefined) {
if (typeof opts.body === 'string') {
body = opts.body
// если уже задан Content-Type — оставляем как есть
headers['Content-Type'] ??= 'application/x-www-form-urlencoded'
} else {
body = JSON.stringify(opts.body)
headers['Content-Type'] = 'application/json'
}
}
if (opts.token) headers['Authorization'] = `Bearer ${opts.token}`
const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), opts.timeoutMs ?? 30_000)
let resp: Response
try {
resp = await fetch(url, { method, headers, body, signal: controller.signal })
} finally {
clearTimeout(timer)
}
const text = await resp.text()
if (!resp.ok && !opts.allowError) {
// Sprint 26: парсим Retry-After (может быть в секундах или HTTP-date).
let retryAfter: number | undefined
const ra = resp.headers.get('retry-after')
if (ra) {
const n = Number(ra)
if (Number.isFinite(n) && n > 0) retryAfter = Math.ceil(n)
}
throw new ApiError(resp.status, text, url, method, retryAfter)
}
// 204 / пустой body → undefined
if (text.length === 0) return undefined as T
try {
return JSON.parse(text) as T
} catch {
return text as unknown as T
}
}
/** Sleep helper для retry'ев в фабрике (rate-limit signup). */
export function sleep(ms: number): Promise<void> {
return new Promise(res => setTimeout(res, ms))
}
export const baseUrl = BASE