/** * Хелперы для сценария full-chain (жизнь магазина от регистрации до отчётов). * * Работает против НАСТОЯЩЕГО стенда (E2E_ADMIN_URL, по умолчанию * https://test.admin.food-market.kz). Без моков. Инварианты считаем через API * (не через БД: stage-БД живёт на prod-vm, docker exec сюда не достаёт). * * Два режима запуска: * - C (по умолчанию): своя свежая организация через /api/auth/signup, * имя `e2e-fullchain-`. Чистится ночной уборкой (>3 дней). * - B (FULLCHAIN_TARGET=demo): вход в «Демо-магазин» demo-admin/keeper/cashier, * работа на реальном каталоге; новые сущности с меткой «(e2e)». */ import axios, { AxiosInstance } from 'axios' import { Agent as HttpsAgent } from 'node:https' export const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz' export const TARGET = (process.env.FULLCHAIN_TARGET ?? 'temp').toLowerCase() // 'temp' | 'demo' export const IS_DEMO = TARGET === 'demo' const httpsAgent = new HttpsAgent({ rejectUnauthorized: false }) export function makeClient(token?: string): AxiosInstance { return axios.create({ baseURL: BASE, httpsAgent, timeout: 60_000, headers: token ? { Authorization: `Bearer ${token}` } : {}, validateStatus: () => true, }) } export interface Session { email: string password: string token: string orgId: string roles: string[] } async function tokenFor(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', }) const r = await makeClient().post('/connect/token', body, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, }) if (r.status !== 200) throw new Error(`token ${email}: ${r.status} ${JSON.stringify(r.data).slice(0, 200)}`) return r.data.access_token as string } export async function me(token: string): Promise<{ orgId: string; roles: string[]; advancedMode: boolean; sub: string }> { const r = await makeClient(token).get('/api/me') if (r.status !== 200) throw new Error(`/api/me: ${r.status}`) return { orgId: r.data.orgId, roles: r.data.roles ?? [], advancedMode: !!r.data.advancedMode, sub: r.data.sub } } /** Регистрация свежей организации (режим C). Имя с меткой e2e-fullchain-. */ export async function signupOrg(): Promise { const ts = Date.now() const email = `e2e-fullchain-${ts}@food-market.local` const password = 'UiTest12345!' const orgName = `e2e-fullchain-${ts}` const api = makeClient() let r = await api.post('/api/auth/signup', { email, password, organizationName: orgName, phone: '+77011190001', plan: 'start' }) for (let i = 0; i < 6 && r.status === 429; i++) { await sleep(12_000) r = await api.post('/api/auth/signup', { email, password, organizationName: orgName, phone: '+77011190001', plan: 'start' }) } if (r.status !== 200) throw new Error(`signup: ${r.status} ${JSON.stringify(r.data).slice(0, 200)}`) const token = await tokenFor(email, password) const m = await me(token) return { email, password, token, orgId: m.orgId, roles: m.roles } } /** Вход в «Демо-магазин» одним из demo-* (режим B); пароль — quick-login master. */ export async function demoLogin(which: 'admin' | 'keeper' | 'cashier'): Promise { const email = `demo-${which}@food-market.local` // На стенде demo-трио логинится quick-login sentinel-паролем (беспарольный // вход). Реальный пароль demo-юзеров неизвестен; sentinel работает пока // QuickLogin:Enabled=true и Issuer не прод. const password = process.env.FULLCHAIN_DEMO_PASS ?? '__QUICK_LOGIN_PWLESS__' const token = await tokenFor(email, password) const m = await me(token) return { email, password, token, orgId: m.orgId, roles: m.roles } } /** Логин по известным кредам (для проверки блокировки, повторных входов). */ export async function loginCreds(email: string, password: string): Promise<{ ok: boolean; status: number; token?: string; refresh?: string }> { const body = new URLSearchParams({ grant_type: 'password', username: email, password, client_id: 'food-market-web', scope: 'openid profile email roles api offline_access', }) const r = await makeClient().post('/connect/token', body, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }) return { ok: r.status === 200, status: r.status, token: r.data?.access_token, refresh: r.data?.refresh_token } } export async function refreshToken(refresh: string): Promise<{ ok: boolean; status: number }> { const body = new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refresh, client_id: 'food-market-web', }) const r = await makeClient().post('/connect/token', body, { headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }) return { ok: r.status === 200, status: r.status } } // ── числовые помощники ───────────────────────────────────────────────────── export const round2 = (x: number) => Math.round((x + Number.EPSILON) * 100) / 100 export const approxEq = (a: number, b: number, eps = 0.01) => Math.abs(a - b) < eps export const sum = (xs: number[]) => xs.reduce((a, b) => a + b, 0) export const sleep = (ms: number) => new Promise((res) => setTimeout(res, ms)) /** EAN-13 с валидной контрольной цифрой. idx (0..999) кодируется в младших * разрядах 12-значного тела ДО контрольной цифры, время прогона — в середине, * поэтому коды уникальны и между прогонами, и между товарами (idx). */ export function ean13(idx: number): string { const timePart = String(Date.now() % 100_000_000).padStart(8, '0') // 8 цифр const base = ('2' + timePart + String(idx % 1000).padStart(3, '0')).slice(0, 12) // 1+8+3=12 let s = 0 for (let i = 0; i < 12; i++) s += Number(base[i]) * (i % 2 === 0 ? 1 : 3) const check = (10 - (s % 10)) % 10 return base + check } /** Проверка контрольной цифры EAN-13 (справа налево, веса 3,1,3,1…). */ export function isValidEan13(code: string): boolean { if (!/^\d{13}$/.test(code)) return false let s = 0, w = 3 for (let i = 11; i >= 0; i--) { s += Number(code[i]) * w; w = w === 3 ? 1 : 3 } return (10 - (s % 10)) % 10 === Number(code[12]) }