food-market/tests/regression/factories/OrgFactory.ts
nns 1989db32bb test(s16): regression suite 35 flows + visual 60 snapshots + nightly + CI badges
Sprint 16 — постоянный regression-контур: flows + visual + nightly +
CI workflow + README badges.

Ключевые цифры:
- 35 flow-тестов: 35/35 ✓ за ~30 секунд (workers=2 локально).
- 60 visual snapshot'ов (15 страниц × 2 темы × 2 viewport'a):
  60/60 ✓ за ~4 минуты с retries=1.
- Полный регресс прогон: ~5 минут (цель была < 15).

Что сделано:
1. tests/regression/ — Playwright + factories + 8 spec-файлов.
   OrgFactory builder создаёт org через API за O(N) HTTP вызовов
   (signup → token → refs → products → counterparties → posted supplies).
   Каждый flow независим, использует свой fresh-org.
2. tests/regression/visual/ — 15 страниц × 2 темы × 2 viewport'a.
   Маски на динамический контент (артикулы с Date.now, KPI'ы,
   delta-стрелки) чтобы 0.2% threshold не флакал. snapshotPathTemplate
   c {projectName} — desktop+mobile не затирают друг друга.
3. tests/regression/factories/OrgFactory.ts — builder с .withProducts
   .withCounterparties .withSupplies. Retry signup'a на 429.
4. .forgejo/workflows/regression.yml — on workflow_run после
   Docker API/Web; cache на pnpm-store + Playwright-browsers;
   артефакты при failure; Telegram-уведомление в обоих случаях.
5. ~/nightly-verify.sh + cron `0 4 * * *`: health → redeploy если
   нужно → smoke flows; в воскресенье полный flows+visual. Логи с
   ротацией 14 дней. Telegram на провал (~/.fm-watchdog/telegram-*).
6. scripts/generate-badges.sh — coverage из cobertura.xml в SVG через
   shields.io (offline fallback). 4 CI-status badge + coverage badge в
   README; CI step «Update coverage badge» авто-коммитит обновлённый
   SVG на push в main.

Локальное число flake'ов: 1/60 visual на retry=1 (product-new light) —
случайная гонка маски, retry'ит и проходит.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-07 16:14:11 +05:00

277 lines
10 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: OrgFactory — главная фабрика тестовых данных для regression.
*
* Создаёт через API за O(N) HTTP вызовов:
* - Organization + Admin user (signup),
* - access_token,
* - N товаров,
* - M контрагентов (опционально),
* - K документов (опционально).
*
* Возвращает один объект OrgSession + опционально кеш ссылок (refIds,
* products, counterparties, supplies). Используется в каждом
* regression-flow вместо повторного signup + form-fill.
*
* Дизайн: builder-pattern с цепочкой `.withProducts(N).withSupplies(M).build()`,
* чтобы тест запросил ровно тот фикстур-набор который нужен. Без
* "монстро-фабрики" которая всегда делает всё.
*/
import { ApiError, request, sleep } from './api-client.js'
import {
Endpoints,
type CounterpartyRef,
type DocumentRef,
type OrgSession,
type ProductRef,
type RefIds,
} from './types.js'
interface SignupResult {
organizationId: string
email: string
}
interface TokenResult {
access_token: string
refresh_token: string
}
export interface BuiltOrg {
session: OrgSession
refs: RefIds
products: ProductRef[]
counterparties: CounterpartyRef[]
supplies: DocumentRef[]
/** Headers с Bearer-токеном для запросов из теста. */
authHeaders: { Authorization: string; 'Content-Type': string }
}
interface FactoryOptions {
productsCount: number
counterpartiesCount: number
/** Создать приёмки (Supply.Post) — N штук. У каждой одна линия с productCount/N
* товарами по 100шт. Без них товары будут без остатка. */
suppliesCount: number
/** Префикс для slug'а (email/orgName) — удобно для дебага. */
slug: string
}
const DEFAULT_OPTS: FactoryOptions = {
productsCount: 0,
counterpartiesCount: 0,
suppliesCount: 0,
slug: 'reg',
}
export class OrgFactory {
private opts: FactoryOptions = { ...DEFAULT_OPTS }
static for(slug: string): OrgFactory {
const f = new OrgFactory()
f.opts.slug = slug
return f
}
withProducts(n: number): this { this.opts.productsCount = n; return this }
withCounterparties(n: number): this { this.opts.counterpartiesCount = n; return this }
withSupplies(n: number): this { this.opts.suppliesCount = n; return this }
async build(): Promise<BuiltOrg> {
const { slug } = this.opts
const ts = Date.now() + Math.floor(Math.random() * 9999)
const email = `${slug}-${ts}@food-market.local`
const password = 'RegTest12345!'
const orgName = `Reg-${slug}-${ts}`
// 1) Signup. Сервер rate-limit'ит signup (Sprint 13: 3/час/IP на prod,
// 30/час/IP на stage env). Под нагрузкой ждём + повторяем.
const signup = await this.signupWithRetry(email, password, orgName)
// 2) Token.
const token = await this.getToken(email, password)
// 3) Refs (units / groups / stores / retail-points / currencies / price-types).
const refs = await this.loadRefs(token.access_token)
const built: BuiltOrg = {
session: {
email, password, orgName,
orgId: signup.organizationId,
accessToken: token.access_token,
refreshToken: token.refresh_token,
},
refs,
products: [],
counterparties: [],
supplies: [],
authHeaders: {
Authorization: `Bearer ${token.access_token}`,
'Content-Type': 'application/json',
},
}
// 4) Products (опц.).
if (this.opts.productsCount > 0) {
built.products = await this.createProducts(token.access_token, refs, this.opts.productsCount)
}
// 5) Counterparties (опц.).
if (this.opts.counterpartiesCount > 0) {
built.counterparties = await this.createCounterparties(token.access_token, this.opts.counterpartiesCount)
}
// 6) Supplies (опц.) — нужны для тестов на остаток/продажу.
if (this.opts.suppliesCount > 0) {
if (built.products.length === 0)
throw new Error('Supplies require at least 1 product — call .withProducts(N) first')
if (built.counterparties.length === 0) {
// авто-создаём 1 поставщика чтобы supplyлогика не падала
built.counterparties = await this.createCounterparties(token.access_token, 1)
}
built.supplies = await this.createPostedSupplies(token.access_token, refs, built, this.opts.suppliesCount)
}
return built
}
// ─ private helpers ────────────────────────────────────────────────────
private async signupWithRetry(email: string, password: string, orgName: string): Promise<SignupResult> {
let lastErr: unknown
for (let i = 0; i < 4; i++) {
try {
return await request<SignupResult>(Endpoints.signup, {
body: { email, password, organizationName: orgName, phone: '+77001234567' },
})
} catch (e) {
if (e instanceof ApiError && e.status === 429) {
// Sprint 13: на stage'е RATE_SIGNUP_HOUR=30, под параллельным
// workers (4) можем упереться. Ждём 5с и повторяем (макс 4 попытки).
await sleep(5_000 * (i + 1))
lastErr = e
continue
}
throw e
}
}
throw lastErr ?? new Error('signup failed after retries')
}
private async getToken(email: string, password: string): Promise<TokenResult> {
const body = new URLSearchParams({
grant_type: 'password',
username: email,
password,
client_id: 'food-market-web',
scope: 'openid profile email roles api offline_access',
}).toString()
return request<TokenResult>(Endpoints.token, {
body,
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
})
}
private async loadRefs(token: string): Promise<RefIds> {
interface Paged<T> { items: T[] }
interface ItemBase { id: string }
interface Unit extends ItemBase { code: string }
interface PriceType extends ItemBase { isRetail: boolean }
interface Currency extends ItemBase { code: string }
interface Store extends ItemBase { isMain: boolean }
const [units, groups, stores, rps, curs, pts] = await Promise.all([
request<Paged<Unit>>(Endpoints.refs.units, { token }),
request<Paged<ItemBase>>(Endpoints.refs.groups, { token }),
request<Paged<Store>>(Endpoints.refs.stores, { token }),
request<Paged<ItemBase>>(Endpoints.refs.retailPoints, { token }),
request<Paged<Currency>>(Endpoints.refs.currencies, { token }),
request<Paged<PriceType>>(Endpoints.refs.priceTypes, { token }),
])
const unit = units.items.find(u => u.code === '796') ?? units.items[0]
const group = groups.items[0]
const store = stores.items.find(s => s.isMain) ?? stores.items[0]
const rp = rps.items[0]
const cur = curs.items.find(c => c.code === 'KZT') ?? curs.items[0]
const pt = pts.items.find(p => p.isRetail) ?? pts.items[0]
if (!unit || !group || !store || !rp || !cur || !pt) {
throw new Error(`refs incomplete: unit=${!!unit} group=${!!group} store=${!!store} rp=${!!rp} cur=${!!cur} pt=${!!pt}`)
}
return {
unitId: unit.id, groupId: group.id,
storeId: store.id, retailPointId: rp.id,
currencyId: cur.id, priceTypeId: pt.id,
}
}
private async createProducts(token: string, refs: RefIds, n: number): Promise<ProductRef[]> {
const out: ProductRef[] = []
for (let i = 0; i < n; i++) {
const name = `Товар ${i + 1}`
const article = `ART-${this.opts.slug}-${Date.now()}-${i}`
const barcode = this.randomBarcode()
const product = await request<{ id: string }>(Endpoints.products, {
token,
body: {
name, article, unitOfMeasureId: refs.unitId,
vat: 12, vatEnabled: true,
productGroupId: refs.groupId,
packaging: 1,
prices: [{ priceTypeId: refs.priceTypeId, amount: 100 + i * 10, currencyId: refs.currencyId }],
barcodes: [{ code: barcode, type: 1, isPrimary: true }],
},
})
out.push({ id: product.id, name, article })
}
return out
}
private async createCounterparties(token: string, n: number): Promise<CounterpartyRef[]> {
const out: CounterpartyRef[] = []
for (let i = 0; i < n; i++) {
const name = `Контрагент ${i + 1}`
// Чередуем типы: чётные — юрлица, нечётные — физлица.
const type = i % 2 === 0 ? 1 : 2
const cp = await request<{ id: string }>(Endpoints.counterparties, {
token,
body: { name, type, bin: type === 1 ? '123456789012' : null },
})
out.push({ id: cp.id, name, type })
}
return out
}
private async createPostedSupplies(
token: string, refs: RefIds, built: BuiltOrg, n: number,
): Promise<DocumentRef[]> {
const supplier = built.counterparties[0]!
const out: DocumentRef[] = []
// На каждую приёмку — все products с qty 100 / unitPrice 50.
// Это даёт быстрый stock=100×N для дальнейших sales-flow.
const lines = built.products.map(p => ({
productId: p.id, quantity: 100, unitPrice: 50,
}))
for (let i = 0; i < n; i++) {
const draft = await request<{ id: string; number: string }>(Endpoints.supplies, {
token,
body: {
date: new Date().toISOString(),
supplierId: supplier.id,
storeId: refs.storeId,
currencyId: refs.currencyId,
lines,
},
})
// Post — отдельный endpoint, NoContent на успехе.
await request(`${Endpoints.supplies}/${draft.id}/post`, { token, method: 'POST' })
out.push({ id: draft.id, number: draft.number })
}
return out
}
private randomBarcode(): string {
let s = ''
for (let i = 0; i < 13; i++) s += Math.floor(Math.random() * 10)
return s
}
}