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>
132 lines
6.9 KiB
TypeScript
132 lines
6.9 KiB
TypeScript
/**
|
||
* Хелперы для сценария 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])
|
||
}
|