food-market/tests/e2e/lib/fc.ts
nns 886cb92f30 test(e2e): сквозной сценарий full-chain «жизнь магазина» + харнес со скриншотами
16 шагов против настоящего стенда: регистрация→справочники→сотрудники/точки→
деньги→3 приёмки→оплаты→смена/продажи/возврат/Z→склад→отчёты→инварианты→откат.
Сверяет конкретные числа и инварианты, 0×5xx/0×тостов/0×console.error. Режимы
C (temp-орг) и B (Демо на реальном каталоге). Hi-res скриншоты каждого процесса +
галерея. Включён в nightly-verify и quality-watchdog (скрипты в $HOME).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 02:10:11 +05:00

132 lines
6.9 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.

/**
* Хелперы для сценария full-chain (жизнь магазина от регистрации до отчётов).
*
* Работает против НАСТОЯЩЕГО стенда (E2E_ADMIN_URL, по умолчанию
* https://test.admin.food-market.kz). Без моков. Инварианты считаем через API
* (не через БД: stage-БД живёт на prod-vm, docker exec сюда не достаёт).
*
* Два режима запуска:
* - C (по умолчанию): своя свежая организация через /api/auth/signup,
* имя `e2e-fullchain-<ts>`. Чистится ночной уборкой (>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<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' },
})
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-<ts>. */
export async function signupOrg(): Promise<Session> {
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<Session> {
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<void>((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])
}