/** * 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 { 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 { return await this.retryOn429(4, () => request(Endpoints.signup, { body: { email, password, organizationName: orgName, phone: '+77001234567' }, })) } /** * Sprint 26: общий retry-helper для 429-ответов. Honors Retry-After header * если сервер прислал; иначе короткий backoff с jitter (1-3s × attempt). * Stage rate-limit поднят до 5000/min, поэтому реально 429 ловить почти * не должны — этот retry на крайний случай. */ private async retryOn429(maxAttempts: number, fn: () => Promise): Promise { let lastErr: unknown for (let i = 0; i < maxAttempts; i++) { try { return await fn() } catch (e) { if (e instanceof ApiError && e.status === 429) { const headerDelay = (e.retryAfterSec ?? 0) * 1000 const backoff = 1_000 + i * 1_500 + Math.floor(Math.random() * 1_000) // 1-2s, 2.5-3.5s, 4-5s, 5.5-6.5s await sleep(Math.min(Math.max(headerDelay, backoff), 8_000)) lastErr = e continue } throw e } } throw lastErr ?? new Error('429 retry exhausted') } private async getToken(email: string, password: string): Promise { 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 await this.retryOn429(8, () => request(Endpoints.token, { body, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, })) } private async loadRefs(token: string): Promise { interface Paged { 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>(Endpoints.refs.units, { token }), request>(Endpoints.refs.groups, { token }), request>(Endpoints.refs.stores, { token }), request>(Endpoints.refs.retailPoints, { token }), request>(Endpoints.refs.currencies, { token }), request>(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 { 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 { 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 { 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 } }