Декларативные end-to-end сценарии в tests/e2e/. YAML описывает шаги, TypeScript-handler — конкретные API/UI/DB-проверки. Отчёт в Markdown. Структура: - runner.ts : entry, парсит YAML, прогоняет steps, пишет report - run.sh : pnpm install + tsx - lib/api.ts : axios + login() (через /connect/token + /api/me) - lib/db.ts : docker exec psql, resetTenantData(), countRows() - lib/report.ts : Markdown-аккумулятор (steps + bugs + ux + gap + perf) - scenarios/full-cycle.yml : 12 шагов - scenarios/full-cycle.steps.ts : handlers (один на шаг) - README.md : как добавить новый сценарий reset_db в preconditions: - TRUNCATE tenant-таблиц CASCADE - AspNet*/users — оставляем только admin@food-market.local - OpenIddict tokens — все valid → revoked - Реестр products + системные справочники + миграции + platform_settings — НЕ трогаем Запуск: tests/e2e/run.sh full-cycle [--api-only] Первый прогон (--api-only, baseline в reports/full-cycle-2026-05-07-baseline.md): - 8 ✓ / 1 ✗ / 3 ◯ из 12. - Critical bug: Cashier видит /api/organization/employees через API (нет [Authorize(Roles="Admin")] на List endpoint). - High: при CreateOrg через SuperAdmin не сидируются tenant-units — пустой каталог измерений у новой org (DevDataSeeder.SeedTenantReferencesAsync должен вызываться, но не вызывается). - Logic gaps: реестр products tenant-scoped и новая org стартует с пустым каталогом; SuperAdmin /organizations не валидирует ФЛК телефона; Cashier не получает Identity-роль "Cashier" при создании через /employees. UI-шаги (Playwright) в этом коммите не покрыты — runner работает в --api-only режиме. UI-extension добавим следующим коммитом, не блокирует получение полезного отчёта.
697 lines
29 KiB
TypeScript
697 lines
29 KiB
TypeScript
/**
|
||
* Step-handlers для full-cycle сценария. Каждая функция получает
|
||
* { ctx, step, report }
|
||
* и обновляет step.checks и report.bugs / report.uxNotes / report.gap.
|
||
*
|
||
* ctx — общий накопитель ID'ов созданных сущностей и активных сессий
|
||
* между steps. Структура шире чем строго необходима — для удобства
|
||
* логирования в отчёте.
|
||
*/
|
||
import { login, makeClient, ADMIN_BASE } from '../lib/api.js'
|
||
import type { CheckResult, Step, Report } from '../lib/report.js'
|
||
import { Report as _R } from '../lib/report.js' // type-only ниже
|
||
import { countRows, psql } from '../lib/db.js'
|
||
|
||
type Ctx = {
|
||
apiOnly: boolean
|
||
superAdminToken?: string
|
||
organization?: { id: string; name: string }
|
||
adminEmail?: string
|
||
adminTempPassword?: string
|
||
adminToken?: string
|
||
storekeeperEmail?: string
|
||
cashierEmail?: string
|
||
cashierTempPassword?: string
|
||
storekeeperTempPassword?: string
|
||
counterpartyId?: string
|
||
storeId?: string
|
||
retailPointId?: string
|
||
supplyId?: string
|
||
supplyLines?: { productId: string; productName: string; quantity: number; price: number }[]
|
||
retailSaleId?: string
|
||
saleLines?: { productId: string; quantity: number }[]
|
||
stockBefore?: Record<string, number>
|
||
stockAfterSupply?: Record<string, number>
|
||
}
|
||
|
||
interface StepCtx { ctx: Ctx; step: Step; report: Report }
|
||
|
||
const TIMESTAMP = Date.now()
|
||
|
||
function check(step: Step, c: CheckResult) {
|
||
step.checks.push(c)
|
||
}
|
||
|
||
function asString(x: unknown): string {
|
||
if (x == null) return ''
|
||
if (typeof x === 'string') return x
|
||
return JSON.stringify(x)
|
||
}
|
||
|
||
async function ensureSuperAdminToken(ctx: Ctx): Promise<string> {
|
||
if (!ctx.superAdminToken) {
|
||
const sa = await login('admin@food-market.local', 'Admin12345!')
|
||
ctx.superAdminToken = sa.accessToken
|
||
}
|
||
return ctx.superAdminToken
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function step01_create_organization({ ctx, step, report }: StepCtx) {
|
||
const token = await ensureSuperAdminToken(ctx)
|
||
const api = makeClient(token)
|
||
const orgName = `Test Shop ${TIMESTAMP}`
|
||
const orgEmail = `e2e-${TIMESTAMP}@example.kz`
|
||
|
||
const res = await api.post('/api/super-admin/organizations', {
|
||
org: {
|
||
name: orgName,
|
||
countryCode: 'KZ',
|
||
bin: '123456789012',
|
||
address: 'Алматы, ул. Тестовая 1',
|
||
phone: '+77001234567',
|
||
email: orgEmail,
|
||
defaultCurrencyId: null,
|
||
accountOwnerUserId: null,
|
||
},
|
||
adminLastName: 'Тестов',
|
||
adminFirstName: 'Админ',
|
||
adminEmail: `admin-${TIMESTAMP}@example.kz`,
|
||
adminPosition: 'Директор',
|
||
})
|
||
|
||
check(step, {
|
||
kind: 'api',
|
||
description: `POST /api/super-admin/organizations → 200`,
|
||
ok: res.status === 200,
|
||
detail: res.status === 200 ? `org=${res.data?.organization?.name}` : asString(res.data),
|
||
})
|
||
if (res.status !== 200) {
|
||
report.bug({
|
||
step: '01',
|
||
severity: 'critical',
|
||
title: 'Не удаётся создать организацию через API SuperAdmin',
|
||
detail: `POST /api/super-admin/organizations вернул ${res.status} ${asString(res.data)}`,
|
||
fix: 'Проверь что таблицы tenant-bootstrap (employee_roles системные) сохранены при reset_db.',
|
||
})
|
||
return
|
||
}
|
||
ctx.organization = { id: res.data.organization.id, name: res.data.organization.name }
|
||
ctx.adminEmail = res.data.adminEmail
|
||
ctx.adminTempPassword = res.data.adminTempPassword
|
||
|
||
// List ↦ должен возвращать новую org
|
||
const list = await api.get('/api/super-admin/organizations?archived=false&pageSize=200')
|
||
const found = list.data?.items?.some((o: { id: string }) => o.id === ctx.organization!.id)
|
||
check(step, {
|
||
kind: 'api',
|
||
description: 'GET /api/super-admin/organizations включает созданную org',
|
||
ok: !!found,
|
||
detail: found ? '' : `total=${list.data?.total}`,
|
||
})
|
||
|
||
// ФЛК телефона: проверим что орг с невалидным KZ-телефоном (например +71234567890)
|
||
// отвергается сервером. Делаем pure-API smoke без UI — это проверка backend-validation.
|
||
const bad = await api.post('/api/super-admin/organizations', {
|
||
org: {
|
||
name: `Bad Phone ${TIMESTAMP}`,
|
||
countryCode: 'KZ', bin: null, address: null, phone: 'abc', email: null,
|
||
defaultCurrencyId: null, accountOwnerUserId: null,
|
||
},
|
||
adminLastName: 'X', adminFirstName: 'Y',
|
||
adminEmail: `bad-${TIMESTAMP}@example.kz`, adminPosition: null,
|
||
})
|
||
// Здесь backend может НЕ валидировать phone (это поле опциональное и без regex'а
|
||
// на уровне домена). Поэтому статус 200 — это logic gap, отметим:
|
||
if (bad.status === 200) {
|
||
report.gap('SuperAdmin /organizations принимает любой текст в поле phone — серверной валидации ФЛК нет (только в /api/auth/signup для самозаполнения).')
|
||
} else {
|
||
check(step, {
|
||
kind: 'api',
|
||
description: 'Невалидный phone отвергается',
|
||
ok: bad.status >= 400 && bad.status < 500,
|
||
detail: `${bad.status}`,
|
||
})
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function step02_create_first_admin({ ctx, step, report }: StepCtx) {
|
||
// SuperAdmin при создании org уже создаёт первого Admin'a (см. step01).
|
||
// Проверяем что temp password выдан и в БД появился Employee + Identity-Admin.
|
||
if (!ctx.organization || !ctx.adminEmail) {
|
||
step.status = 'skip'; step.notes.push('Нет organization из step 01 — пропускаем.')
|
||
return
|
||
}
|
||
|
||
check(step, {
|
||
kind: 'api',
|
||
description: 'Temp password возвращён CreateOrgResult',
|
||
ok: !!ctx.adminTempPassword && ctx.adminTempPassword.length >= 8,
|
||
detail: ctx.adminTempPassword ? `len=${ctx.adminTempPassword.length}` : 'empty',
|
||
})
|
||
|
||
const employeesCount = countRows('employees', `"OrganizationId" = '${ctx.organization.id}'`)
|
||
check(step, {
|
||
kind: 'db',
|
||
description: `employees содержит ровно 1 запись для новой org`,
|
||
ok: employeesCount === 1,
|
||
detail: `count=${employeesCount}`,
|
||
})
|
||
|
||
// Проверим что AspNetUserRoles содержит role=Admin для нового user-id.
|
||
const userIdRows = psql(
|
||
`SELECT u."Id" FROM users u WHERE u."Email" = '${ctx.adminEmail}';`).trim()
|
||
if (!userIdRows) {
|
||
check(step, { kind: 'db', description: 'AppUser существует', ok: false, detail: 'не найден' })
|
||
return
|
||
}
|
||
const userId = userIdRows
|
||
const roleRows = psql(
|
||
`SELECT r."Name" FROM "AspNetUserRoles" ur
|
||
JOIN roles r ON r."Id" = ur."RoleId"
|
||
WHERE ur."UserId" = '${userId}';`).trim().split('\n')
|
||
const hasAdmin = roleRows.includes('Admin')
|
||
check(step, {
|
||
kind: 'db',
|
||
description: 'AspNetUserRoles содержит role=Admin для нового user',
|
||
ok: hasAdmin,
|
||
detail: roleRows.join(','),
|
||
})
|
||
if (!hasAdmin) {
|
||
report.bug({
|
||
step: '02',
|
||
severity: 'high',
|
||
title: 'Identity-роль Admin не присвоена при создании организации',
|
||
detail: 'CreateOrg в SuperAdminOrganizationsController должен вызывать UserManager.AddToRoleAsync(user, "Admin")',
|
||
})
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function step03_login_as_admin({ ctx, step, report }: StepCtx) {
|
||
if (!ctx.adminEmail || !ctx.adminTempPassword) {
|
||
step.status = 'skip'; step.notes.push('Нет creds admin'); return
|
||
}
|
||
try {
|
||
const sess = await login(ctx.adminEmail, ctx.adminTempPassword)
|
||
ctx.adminToken = sess.accessToken
|
||
check(step, {
|
||
kind: 'api',
|
||
description: '/connect/token password-grant выдал токен',
|
||
ok: !!sess.accessToken,
|
||
})
|
||
check(step, {
|
||
kind: 'api',
|
||
description: '/api/me содержит role=Admin',
|
||
ok: sess.roles.includes('Admin'),
|
||
detail: sess.roles.join(','),
|
||
})
|
||
check(step, {
|
||
kind: 'api',
|
||
description: '/api/me содержит правильный orgId',
|
||
ok: sess.orgId === ctx.organization?.id,
|
||
detail: sess.orgId ?? 'null',
|
||
})
|
||
} catch (e) {
|
||
check(step, { kind: 'api', description: 'login admin', ok: false, detail: (e as Error).message })
|
||
report.bug({
|
||
step: '03',
|
||
severity: 'critical',
|
||
title: 'Свежесозданный Admin не может залогиниться с temp password',
|
||
detail: (e as Error).message,
|
||
})
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function step04_create_storekeeper_and_cashier({ ctx, step, report }: StepCtx) {
|
||
if (!ctx.adminToken || !ctx.organization) { step.status = 'skip'; return }
|
||
const api = makeClient(ctx.adminToken)
|
||
|
||
// Получаем list ролей; нужны Storekeeper (системная) и Кассир (системная).
|
||
const rolesRes = await api.get('/api/organization/employee-roles?pageSize=200')
|
||
check(step, {
|
||
kind: 'api', description: 'employee-roles list', ok: rolesRes.status === 200,
|
||
detail: `${rolesRes.status}, total=${rolesRes.data?.total}`,
|
||
})
|
||
const roles = (rolesRes.data?.items ?? []) as { id: string; name: string; isSystem: boolean }[]
|
||
const keeperRole = roles.find((r) => r.name === 'Кладовщик')
|
||
const cashierRole = roles.find((r) => r.name === 'Кассир')
|
||
check(step, {
|
||
kind: 'api', description: 'Системная роль «Кладовщик» существует', ok: !!keeperRole,
|
||
})
|
||
check(step, {
|
||
kind: 'api', description: 'Системная роль «Кассир» существует', ok: !!cashierRole,
|
||
})
|
||
if (!keeperRole || !cashierRole) {
|
||
report.bug({
|
||
step: '04', severity: 'high',
|
||
title: 'Не сидируются системные роли при создании org',
|
||
detail: `roles list = ${roles.map((r) => r.name).join(', ')}`,
|
||
fix: 'DevDataSeeder.SeedEmployeeRolesAsync должен вызываться после CreateOrg.',
|
||
})
|
||
return
|
||
}
|
||
|
||
ctx.storekeeperEmail = `keeper-${TIMESTAMP}@example.kz`
|
||
ctx.cashierEmail = `cashier-${TIMESTAMP}@example.kz`
|
||
|
||
for (const [emailField, roleId, lastName, firstName, position] of [
|
||
[ctx.storekeeperEmail, keeperRole.id, 'Кладовщиков', 'Иван', 'Кладовщик'],
|
||
[ctx.cashierEmail, cashierRole.id, 'Кассиров', 'Пётр', 'Кассир'],
|
||
] as const) {
|
||
const res = await api.post('/api/organization/employees', {
|
||
lastName, firstName, middleName: null, position,
|
||
email: emailField, phone: '+77002223344',
|
||
salary: null, taxNumber: null, description: null, imageUrl: null,
|
||
roleId, isActive: true, retailPointIds: [], createAccount: true,
|
||
})
|
||
check(step, {
|
||
kind: 'api', description: `POST /api/organization/employees (${position})`,
|
||
ok: res.status === 200, detail: `${res.status} ${res.status !== 200 ? asString(res.data) : ''}`,
|
||
})
|
||
if (res.status === 200) {
|
||
const tempPwd = res.data?.generatedPassword as string | undefined
|
||
if (position === 'Кладовщик') ctx.storekeeperTempPassword = tempPwd
|
||
else ctx.cashierTempPassword = tempPwd
|
||
}
|
||
}
|
||
|
||
const total = countRows('employees', `"OrganizationId" = '${ctx.organization.id}'`)
|
||
check(step, {
|
||
kind: 'db', description: 'employees total = 3 (admin + keeper + cashier)',
|
||
ok: total === 3, detail: `count=${total}`,
|
||
})
|
||
|
||
// Валидация: невалидный email должен быть отвергнут.
|
||
const bad = await api.post('/api/organization/employees', {
|
||
lastName: 'Bad', firstName: 'Email', middleName: null, position: null,
|
||
email: 'not-an-email', phone: null, salary: null, taxNumber: null,
|
||
description: null, imageUrl: null, roleId: cashierRole.id, isActive: true,
|
||
retailPointIds: [], createAccount: true,
|
||
})
|
||
if (bad.status === 200) {
|
||
report.gap('POST /api/organization/employees принимает невалидный email при createAccount=true (Identity создаёт юзера с UserName=not-an-email).')
|
||
} else {
|
||
check(step, {
|
||
kind: 'api', description: 'Невалидный email отвергается при createAccount',
|
||
ok: bad.status >= 400, detail: `${bad.status}`,
|
||
})
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function step05_login_as_cashier({ ctx, step, report }: StepCtx) {
|
||
if (!ctx.cashierEmail || !ctx.cashierTempPassword) { step.status = 'skip'; return }
|
||
let cashier
|
||
try {
|
||
cashier = await login(ctx.cashierEmail, ctx.cashierTempPassword)
|
||
} catch (e) {
|
||
check(step, { kind: 'api', description: 'login Cashier', ok: false, detail: (e as Error).message })
|
||
report.bug({
|
||
step: '05', severity: 'critical',
|
||
title: 'Cashier не может залогиниться с выданным паролем',
|
||
detail: (e as Error).message,
|
||
})
|
||
return
|
||
}
|
||
check(step, {
|
||
kind: 'api', description: '/api/me содержит роль соответствующую системной Cashier',
|
||
ok: cashier.roles.length > 0, // Identity-роль может отсутствовать (используются org-роли),
|
||
detail: cashier.roles.join(',') || 'no Identity roles',
|
||
})
|
||
// Identity-роль для Cashier — "Cashier"? Проверим. Если она НЕ присвоена,
|
||
// это значит role-guard на сервере основан только на Identity-роли Admin
|
||
// (см. Authorize(Roles="Admin")). Cashier тогда всегда 403 на admin-ресурсах,
|
||
// но и на /sales/retail может не пройти если там стоит [Authorize(Roles="Cashier")].
|
||
if (!cashier.roles.includes('Cashier') && !cashier.roles.includes('Admin')) {
|
||
report.gap('Cashier у созданного через POST /employees не получает Identity-роль "Cashier" — серверная авторизация на /api/sales/retail требует роли Admin или Cashier; нужно либо сидить Identity-роль из org-роли, либо переделать [Authorize] на permissions.')
|
||
}
|
||
|
||
const apiCashier = makeClient(cashier.accessToken)
|
||
|
||
// /settings/employees должен быть 403 для Cashier.
|
||
const empRes = await apiCashier.get('/api/organization/employees')
|
||
check(step, {
|
||
kind: 'api', description: 'Cashier → GET /api/organization/employees → 403',
|
||
ok: empRes.status === 403,
|
||
detail: `${empRes.status}`,
|
||
})
|
||
if (empRes.status !== 403) {
|
||
if (empRes.status === 200) {
|
||
report.bug({
|
||
step: '05', severity: 'critical',
|
||
title: 'Cashier ВИДИТ список сотрудников через API',
|
||
detail: 'GET /api/organization/employees вернул 200 для Cashier; ожидается 403 (нет [Authorize(Roles="Admin")] на List? или Cashier имеет Identity-Admin)',
|
||
fix: 'Поставить [Authorize(Roles="Admin")] на List + проверить что AddToRoleAsync(...,"Cashier") а не "Admin")',
|
||
})
|
||
}
|
||
}
|
||
|
||
const salesRes = await apiCashier.get('/api/sales/retail-sales?pageSize=10')
|
||
check(step, {
|
||
kind: 'api', description: 'Cashier → GET /api/sales/retail-sales — доступен',
|
||
ok: salesRes.status === 200 || salesRes.status === 404,
|
||
detail: `${salesRes.status}`,
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function step06_create_counterparty({ ctx, step, report }: StepCtx) {
|
||
if (!ctx.adminToken) { step.status = 'skip'; return }
|
||
const api = makeClient(ctx.adminToken)
|
||
|
||
// Получим страну KZ
|
||
const countries = await api.get('/api/catalog/countries?pageSize=200')
|
||
const kz = (countries.data?.items as { id: string; code: string }[] | undefined)
|
||
?.find((c) => c.code === 'KZ')
|
||
if (!kz) {
|
||
report.bug({ step: '06', severity: 'high', title: 'Страна KZ отсутствует в справочнике', detail: '' })
|
||
}
|
||
|
||
const res = await api.post('/api/catalog/counterparties', {
|
||
name: 'ТОО Тест Поставщик',
|
||
legalName: 'Товарищество с ограниченной ответственностью «Тест Поставщик»',
|
||
type: 0, // LegalEntity
|
||
bin: '987654321098', iin: null, taxNumber: null,
|
||
countryId: kz?.id ?? null,
|
||
address: 'Алматы, ул. Поставщиков 1',
|
||
phone: '+77003332211', email: 'supplier@example.kz',
|
||
bankName: null, bankAccount: null, bik: null,
|
||
contactPerson: 'Иванов И.И.', notes: null,
|
||
})
|
||
check(step, {
|
||
kind: 'api', description: 'POST /api/catalog/counterparties',
|
||
ok: res.status === 200 || res.status === 201,
|
||
detail: `${res.status} ${res.status >= 400 ? asString(res.data) : ''}`,
|
||
})
|
||
if (res.status === 200 || res.status === 201) {
|
||
ctx.counterpartyId = res.data?.id
|
||
} else {
|
||
report.bug({
|
||
step: '06', severity: 'critical',
|
||
title: 'Создание контрагента падает',
|
||
detail: `${res.status} ${asString(res.data)}`,
|
||
})
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function step07_ensure_main_store({ ctx, step, report }: StepCtx) {
|
||
if (!ctx.adminToken) { step.status = 'skip'; return }
|
||
const api = makeClient(ctx.adminToken)
|
||
const list = await api.get('/api/catalog/stores?pageSize=200')
|
||
check(step, {
|
||
kind: 'api', description: 'GET /api/catalog/stores',
|
||
ok: list.status === 200, detail: `${list.status}`,
|
||
})
|
||
const stores = (list.data?.items ?? []) as { id: string; name: string; isMain: boolean }[]
|
||
let main = stores.find((s) => s.isMain) ?? stores[0]
|
||
if (!main) {
|
||
const create = await api.post('/api/catalog/stores', {
|
||
name: 'Main', code: 'MAIN', address: null, phone: null,
|
||
managerName: null, isMain: true, isActive: true,
|
||
})
|
||
check(step, {
|
||
kind: 'api', description: 'Main store создан вручную',
|
||
ok: create.status === 200 || create.status === 201,
|
||
detail: `${create.status}`,
|
||
})
|
||
if (create.status >= 400) {
|
||
report.bug({
|
||
step: '07', severity: 'high',
|
||
title: 'Main store не сидируется автоматически и не создаётся вручную',
|
||
detail: asString(create.data),
|
||
})
|
||
return
|
||
}
|
||
main = create.data
|
||
} else {
|
||
check(step, {
|
||
kind: 'db', description: 'Main store существует (от bootstrap)',
|
||
ok: true, detail: main.name,
|
||
})
|
||
}
|
||
ctx.storeId = main!.id
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function step08_create_supply({ ctx, step, report }: StepCtx) {
|
||
if (!ctx.adminToken || !ctx.counterpartyId || !ctx.storeId) { step.status = 'skip'; return }
|
||
const api = makeClient(ctx.adminToken)
|
||
|
||
// Берём 3 произвольных products. Реестр products в БД tenant-scoped
|
||
// (ITenantEntity), поэтому после создания новой org `GET /api/catalog/
|
||
// products` возвращает 0 — старые products принадлежат другому tenant'у.
|
||
// Это logic-gap описанный в отчёте; для прогона сценария создаём 3
|
||
// products в новой org прямо сейчас.
|
||
const products = await api.get('/api/catalog/products?pageSize=5')
|
||
let items = (products.data?.items ?? []) as { id: string; name: string; unitId?: string }[]
|
||
if (items.length < 3) {
|
||
report.gap('Реестр products tenant-scoped: новая org стартует с пустым каталогом, хотя в БД лежат products другой org. e2e-сценарий компенсирует созданием 3 products через API.')
|
||
// Получим первую unit-of-measure (системную или из org).
|
||
const unitsRes = await api.get('/api/catalog/units?pageSize=10')
|
||
const unit = (unitsRes.data?.items ?? [])[0] as { id: string; name: string } | undefined
|
||
if (!unit) {
|
||
report.bug({
|
||
step: '08', severity: 'high',
|
||
title: 'Нет ни одной единицы измерения для нового tenant',
|
||
detail: 'Bootstrap должен сидить системные units (шт, кг, л) при создании org.',
|
||
})
|
||
return
|
||
}
|
||
const created: { id: string; name: string }[] = []
|
||
for (let i = 0; i < 3; i++) {
|
||
const cr = await api.post('/api/catalog/products', {
|
||
name: `e2e Product ${i + 1} ${TIMESTAMP}`,
|
||
article: `E2E-${TIMESTAMP}-${i + 1}`,
|
||
barcode: null,
|
||
unitId: unit.id,
|
||
groupId: null,
|
||
retailPrice: 100 + i * 50,
|
||
purchasePrice: 70 + i * 30,
|
||
isActive: true,
|
||
})
|
||
if (cr.status >= 400) {
|
||
report.bug({
|
||
step: '08', severity: 'high',
|
||
title: `Не удалось создать product №${i + 1}`,
|
||
detail: `${cr.status} ${asString(cr.data).slice(0, 200)}`,
|
||
})
|
||
return
|
||
}
|
||
created.push({ id: cr.data.id ?? cr.data.productId, name: cr.data.name })
|
||
}
|
||
items = created.map((p) => ({ id: p.id, name: p.name }))
|
||
check(step, {
|
||
kind: 'api', description: 'Auto-created 3 products для нового tenant',
|
||
ok: true, detail: items.map((i) => i.name).join(', '),
|
||
})
|
||
}
|
||
const lines = items.slice(0, 3).map((p, i) => ({
|
||
productId: p.id,
|
||
productName: p.name,
|
||
quantity: 10 + i * 5,
|
||
price: 100 + i * 50,
|
||
}))
|
||
ctx.supplyLines = lines
|
||
|
||
// Сохраняем stock-snapshot ДО приёмки.
|
||
const stockBefore: Record<string, number> = {}
|
||
for (const ln of lines) stockBefore[ln.productId] = await stockOf(api, ctx.storeId, ln.productId)
|
||
ctx.stockBefore = stockBefore
|
||
|
||
const draft = await api.post('/api/purchases/supplies', {
|
||
counterpartyId: ctx.counterpartyId,
|
||
storeId: ctx.storeId,
|
||
docDate: new Date().toISOString(),
|
||
description: 'e2e draft',
|
||
lines: lines.map((l) => ({
|
||
productId: l.productId, quantity: l.quantity, price: l.price,
|
||
})),
|
||
})
|
||
check(step, {
|
||
kind: 'api', description: 'POST /api/purchases/supplies (Draft)',
|
||
ok: draft.status === 200 || draft.status === 201,
|
||
detail: `${draft.status} ${draft.status >= 400 ? asString(draft.data).slice(0, 200) : ''}`,
|
||
})
|
||
if (draft.status >= 400) {
|
||
report.bug({
|
||
step: '08', severity: 'critical',
|
||
title: 'Не удаётся создать Draft Supply',
|
||
detail: asString(draft.data),
|
||
})
|
||
return
|
||
}
|
||
ctx.supplyId = draft.data?.id ?? draft.data?.supplyId
|
||
if (!ctx.supplyId) {
|
||
report.bug({
|
||
step: '08', severity: 'high',
|
||
title: 'POST /supplies не возвращает id созданного документа',
|
||
detail: asString(draft.data).slice(0, 200),
|
||
})
|
||
return
|
||
}
|
||
|
||
const post = await api.post(`/api/purchases/supplies/${ctx.supplyId}/post`, {})
|
||
check(step, {
|
||
kind: 'api', description: 'POST /api/purchases/supplies/{id}/post (Draft → Posted)',
|
||
ok: post.status === 200 || post.status === 204,
|
||
detail: `${post.status} ${post.status >= 400 ? asString(post.data) : ''}`,
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function step09_check_stock_after_supply({ ctx, step, report }: StepCtx) {
|
||
if (!ctx.adminToken || !ctx.supplyLines || !ctx.storeId) { step.status = 'skip'; return }
|
||
const api = makeClient(ctx.adminToken)
|
||
const after: Record<string, number> = {}
|
||
for (const ln of ctx.supplyLines) {
|
||
after[ln.productId] = await stockOf(api, ctx.storeId, ln.productId)
|
||
}
|
||
ctx.stockAfterSupply = after
|
||
|
||
for (const ln of ctx.supplyLines) {
|
||
const before = ctx.stockBefore![ln.productId] ?? 0
|
||
const now = after[ln.productId] ?? 0
|
||
const delta = now - before
|
||
check(step, {
|
||
kind: 'api',
|
||
description: `stock(${ln.productName}) +${ln.quantity} (было ${before}, стало ${now})`,
|
||
ok: delta === ln.quantity,
|
||
detail: `delta=${delta}, expected=${ln.quantity}`,
|
||
})
|
||
if (delta !== ln.quantity) {
|
||
report.bug({
|
||
step: '09', severity: 'high',
|
||
title: 'Остатки не увеличились после Posted Supply',
|
||
detail: `product=${ln.productName} expected delta=${ln.quantity} actual=${delta}`,
|
||
fix: 'Проверь что SupplyPostHandler / hostedService применяет stock_movements при /post',
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function step10_ensure_retail_point({ ctx, step, report }: StepCtx) {
|
||
if (!ctx.adminToken) { step.status = 'skip'; return }
|
||
const api = makeClient(ctx.adminToken)
|
||
const list = await api.get('/api/catalog/retail-points?pageSize=200')
|
||
const items = (list.data?.items ?? []) as { id: string; name: string }[]
|
||
if (items.length === 0) {
|
||
const create = await api.post('/api/catalog/retail-points', {
|
||
name: 'Касса 1', code: 'C1', storeId: ctx.storeId, isActive: true,
|
||
})
|
||
check(step, {
|
||
kind: 'api', description: 'Создана касса',
|
||
ok: create.status === 200 || create.status === 201, detail: `${create.status}`,
|
||
})
|
||
ctx.retailPointId = create.data?.id
|
||
} else {
|
||
ctx.retailPointId = items[0].id
|
||
check(step, { kind: 'api', description: 'RetailPoint существует', ok: true, detail: items[0].name })
|
||
}
|
||
if (!ctx.retailPointId) {
|
||
report.bug({
|
||
step: '10', severity: 'high',
|
||
title: 'Не получилось гарантировать наличие розничной точки',
|
||
detail: '',
|
||
})
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function step11_create_retail_sale({ ctx, step, report }: StepCtx) {
|
||
if (!ctx.adminToken || !ctx.retailPointId || !ctx.supplyLines) { step.status = 'skip'; return }
|
||
const api = makeClient(ctx.adminToken)
|
||
const lines = ctx.supplyLines.slice(0, 2).map((l) => ({
|
||
productId: l.productId, quantity: 2, price: l.price * 2, // продаём по 2 шт.
|
||
}))
|
||
ctx.saleLines = lines.map((l) => ({ productId: l.productId, quantity: l.quantity }))
|
||
|
||
const draft = await api.post('/api/sales/retail-sales', {
|
||
retailPointId: ctx.retailPointId,
|
||
docDate: new Date().toISOString(),
|
||
description: 'e2e sale',
|
||
lines,
|
||
})
|
||
check(step, {
|
||
kind: 'api', description: 'POST /api/sales/retail-sales (Draft)',
|
||
ok: draft.status === 200 || draft.status === 201,
|
||
detail: `${draft.status} ${draft.status >= 400 ? asString(draft.data).slice(0, 200) : ''}`,
|
||
})
|
||
if (draft.status >= 400) {
|
||
report.bug({
|
||
step: '11', severity: 'critical',
|
||
title: 'Не удаётся создать Draft RetailSale',
|
||
detail: asString(draft.data),
|
||
})
|
||
return
|
||
}
|
||
ctx.retailSaleId = draft.data?.id ?? draft.data?.saleId
|
||
if (!ctx.retailSaleId) {
|
||
report.bug({
|
||
step: '11', severity: 'high',
|
||
title: 'POST /retail-sales не возвращает id',
|
||
detail: asString(draft.data).slice(0, 200),
|
||
})
|
||
return
|
||
}
|
||
|
||
const post = await api.post(`/api/sales/retail-sales/${ctx.retailSaleId}/post`, {})
|
||
check(step, {
|
||
kind: 'api', description: 'POST /retail-sales/{id}/post',
|
||
ok: post.status === 200 || post.status === 204,
|
||
detail: `${post.status} ${post.status >= 400 ? asString(post.data) : ''}`,
|
||
})
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
export async function step12_check_stock_after_sale({ ctx, step, report }: StepCtx) {
|
||
if (!ctx.adminToken || !ctx.saleLines || !ctx.storeId || !ctx.stockAfterSupply) { step.status = 'skip'; return }
|
||
const api = makeClient(ctx.adminToken)
|
||
|
||
for (const ln of ctx.saleLines) {
|
||
const before = ctx.stockAfterSupply[ln.productId] ?? 0
|
||
const now = await stockOf(api, ctx.storeId, ln.productId)
|
||
const delta = before - now
|
||
check(step, {
|
||
kind: 'api',
|
||
description: `stock product=${ln.productId.slice(0, 8)}… −${ln.quantity} (было ${before}, стало ${now})`,
|
||
ok: delta === ln.quantity,
|
||
detail: `delta=${delta}, expected=${ln.quantity}`,
|
||
})
|
||
if (delta !== ln.quantity) {
|
||
report.bug({
|
||
step: '12', severity: 'high',
|
||
title: 'Остатки не уменьшились после Posted RetailSale',
|
||
detail: `expected delta=${ln.quantity} actual=${delta}`,
|
||
})
|
||
}
|
||
}
|
||
}
|
||
|
||
// ---------------------------------------------------------------------------
|
||
|
||
async function stockOf(api: ReturnType<typeof makeClient>, storeId: string, productId: string): Promise<number> {
|
||
const res = await api.get(`/api/inventory/stock?productId=${productId}&pageSize=200`)
|
||
if (res.status !== 200) return 0
|
||
const items = (res.data?.items ?? []) as { storeId?: string; quantity: number; productId: string }[]
|
||
// Fallback: если нет фильтра по storeId на endpoint'е, фильтруем сами.
|
||
const row = items.find((i) => i.storeId === storeId && i.productId === productId)
|
||
return row?.quantity ?? 0
|
||
}
|