food-market/tests/e2e/scenarios/moysklad-import.steps.ts
nns c7ecc39590 test(e2e): scenario moysklad-import + mock-сервер MoySklad
lib/moysklad-mock.ts — минимальный mock JSON-API remap 1.2 (organization/
counterparty/product/productfolder) с полями по MoySkladDtos. Сценарий (7
шагов): сохранение/маскирование токена, test-connection, импорт контрагентов
и товаров через фоновый job, идемпотентность повторного импорта
(overwrite=false → Skipped), обновление по ключу (overwrite=true → Updated),
и проверка маппинга в БД (BIN/тип/адрес контрагента; артикул/НДС/упаковка/
цена/штрихкод/группа/страна товара).

Требует запуск API с MoySklad__BaseUrl=http://127.0.0.1:5099/api/remap/1.2/.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 11:27:16 +05:00

272 lines
17 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.

/**
* Step-handlers для moysklad-import.
*
* Поднимаем mock MoySklad (lib/moysklad-mock.ts) на фиксированном порту, на
* который заранее наведён API через MoySklad:BaseUrl. Прогоняем импорт через
* реальные эндпоинты /api/admin/moysklad/* + фоновый job /api/admin/jobs/{id}
* и проверяем результат и в job-счётчиках, и в БД (маппинг полей).
*
* Порт mock-сервера ДОЛЖЕН совпадать с MoySklad__BaseUrl, с которым запущен API
* (см. tests/e2e/README / запуск): http://127.0.0.1:5099/api/remap/1.2/.
*/
import { login, makeClient } from '../lib/api.js'
import { psql } from '../lib/db.js'
import { generateEan13 } from '../lib/barcode.js'
import { startMoySkladMock, type MockHandle, type MsDataset } from '../lib/moysklad-mock.js'
import type { CheckResult, Step, Report } from '../lib/report.js'
import type { AxiosInstance } from 'axios'
const TS = Date.now()
const MOCK_PORT = 5099
const MOCK_BASE = `http://127.0.0.1:${MOCK_PORT}/api/remap/1.2/`
interface Ctx {
apiOnly: boolean
superAdminToken?: string
adminToken?: string
orgId?: string
folderId?: string
ean?: string
countryName?: string
countryId?: string
}
interface StepCtx { ctx: Ctx; step: Step; report: Report }
let mock: MockHandle | undefined
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
try { return JSON.stringify(x).slice(0, 200) } catch { return String(x) }
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
function q1(sql: string): string { return (psql(sql).trim().split('\n')[0] ?? '').trim() }
function cpField(orgId: string, name: string, col: string): string {
return q1(`SELECT "${col}" FROM counterparties WHERE "OrganizationId"='${orgId}' AND "Name"='${name}'`)
}
function cpCount(orgId: string): number {
return Number(q1(`SELECT count(*) FROM counterparties WHERE "OrganizationId"='${orgId}'`)) || 0
}
function prodCount(orgId: string): number {
return Number(q1(`SELECT count(*) FROM products WHERE "OrganizationId"='${orgId}'`)) || 0
}
// Строит набор данных mock'а. label/phone варьируем для overwrite-теста.
function buildDataset(ctx: Ctx, phoneSuffix = ''): MsDataset {
return {
organization: { name: `Mock Org ${TS}`, inn: '600700800900' },
counterparties: [
{
name: `ТОО Ромашка ${TS}`, legalTitle: `Товарищество Ромашка ${TS}`,
companyType: 'legal', inn: '123456789012', kpp: 'KPP001',
phone: `+770111122${phoneSuffix || '33'}`, email: `romashka-${TS}@example.kz`,
actualAddress: 'Алматы Абая 1', description: 'поставщик овощей', archived: false,
},
{
name: `ИП Иванов ${TS}`, companyType: 'entrepreneur', inn: '850101300123',
phone: `+770233344${phoneSuffix || '55'}`, email: `ivanov-${TS}@example.kz`,
legalAddress: 'Астана Победы 5', archived: false,
},
],
folders: [
{ id: ctx.folderId, name: `Бакалея ${TS}`, pathName: '', archived: false },
],
products: [
{
id: `p-${TS}`, name: `Сахар-песок 1кг ${TS}`, article: `SUG-${TS}`, code: `C${TS}`,
description: 'белый сахар', weighed: false, vat: 12, vatEnabled: true, archived: false,
trackingType: 'NOT_TRACKED',
barcodes: [{ ean13: ctx.ean }],
salePrices: [
{ value: 35000, priceType: { name: 'Цена продажи' } },
{ value: 32000, priceType: { name: 'Розничная цена' } },
],
buyPrice: { value: 25000 },
productFolder: { meta: { href: `${MOCK_BASE}entity/productfolder/${ctx.folderId}`, type: 'productfolder' } },
country: { name: ctx.countryName },
},
],
}
}
async function waitJob(api: AxiosInstance, jobId: string, timeoutMs = 20000): Promise<Record<string, any> | null> {
const start = Date.now()
while (Date.now() - start < timeoutMs) {
const r = await api.get(`/api/admin/jobs/${jobId}`)
if (r.status === 200 && (r.data.status !== 'Running' || r.data.finishedAt)) return r.data
await sleep(250)
}
return null
}
// ---------------------------------------------------------------------------
export async function step01_bootstrap_and_connect({ ctx, step, report }: StepCtx) {
const sa = (await login('admin@food-market.local', 'Admin12345!')).accessToken
ctx.superAdminToken = sa
const orgRes = await makeClient(sa).post('/api/super-admin/organizations', {
org: {
name: `MoySklad ${TS}`, countryCode: 'KZ',
bin: null, address: null, phone: null, email: null, defaultCurrencyId: null, accountOwnerUserId: null,
},
adminLastName: 'MS', adminFirstName: 'Admin',
adminEmail: `moysklad-${TS}@example.kz`, adminPosition: null,
})
if (orgRes.status !== 200) { report.bug({ step: '01', severity: 'critical', title: 'bootstrap орг', detail: asString(orgRes.data) }); return }
ctx.orgId = orgRes.data.organization.id
ctx.adminToken = (await login(orgRes.data.adminEmail, orgRes.data.adminTempPassword)).accessToken
const api = makeClient(ctx.adminToken)
// Реальное имя страны из справочника — для детерминированного маппинга country.
const countries = await api.get('/api/catalog/countries?pageSize=300')
const kz = (countries.data?.items ?? countries.data ?? []).find((c: { name: string }) => /Казахстан/i.test(c.name))
?? (countries.data?.items ?? countries.data ?? [])[0]
ctx.countryName = kz?.name
ctx.countryId = kz?.id
ctx.folderId = `f-${TS}`
ctx.ean = generateEan13(1)
// Mock + наведённый на него API.
mock = await startMoySkladMock(MOCK_PORT)
mock.setData(buildDataset(ctx))
check(step, { kind: 'api', description: 'Mock MoySklad поднят', ok: !!mock, detail: MOCK_BASE })
// Сохранение токена + маскирование.
const token = `mocktoken${TS}abcd`
const put = await api.put('/api/admin/moysklad/settings', { token })
check(step, { kind: 'api', description: 'PUT settings → hasToken + маска',
ok: put.status === 200 && put.data.hasToken === true && /•/.test(put.data.masked ?? ''),
detail: `status=${put.status} masked=${put.data?.masked}` })
const get = await api.get('/api/admin/moysklad/settings')
check(step, { kind: 'api', description: 'GET settings не отдаёт сырой токен',
ok: get.data.hasToken === true && !String(get.data.masked ?? '').includes(token),
detail: `masked=${get.data?.masked}` })
// test-connection: API сходит в mock entity/organization.
const test = await api.post('/api/admin/moysklad/test', {})
check(step, { kind: 'api', description: 'POST test → 200, имя орги из mock',
ok: test.status === 200 && test.data.organization === `Mock Org ${TS}`,
detail: `status=${test.status} org=${asString(test.data)}` })
if (test.status !== 200) report.bug({ step: '01', severity: 'critical',
title: 'test-connection не дошёл до mock MoySklad',
detail: `Проверь, что API запущен с MoySklad__BaseUrl=${MOCK_BASE}. Ответ: ${asString(test.data)}` })
}
export async function step02_import_counterparties_create({ ctx, step, report }: StepCtx) {
if (!ctx.orgId || !mock) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken!)
const r = await api.post('/api/admin/moysklad/import-counterparties', { overwriteExisting: false })
check(step, { kind: 'api', description: 'import-counterparties → jobId', ok: r.status === 200 && !!r.data.jobId, detail: `status=${r.status}` })
if (!r.data?.jobId) return
const job = await waitJob(api, r.data.jobId)
check(step, { kind: 'api', description: 'job Succeeded', ok: job?.status === 'Succeeded', detail: `status=${job?.status} msg=${job?.message}` })
check(step, { kind: 'api', description: 'Created=2, Skipped=0',
ok: job?.created === 2 && job?.skipped === 0, detail: `created=${job?.created} updated=${job?.updated} skipped=${job?.skipped}` })
check(step, { kind: 'db', description: 'В БД 2 контрагента', ok: cpCount(ctx.orgId) === 2, detail: `count=${cpCount(ctx.orgId)}` })
// Маппинг ТОО Ромашка (companyType=legal → LegalEntity=1).
const ro = `ТОО Ромашка ${TS}`
check(step, { kind: 'db', description: 'Ромашка: Type=1(LegalEntity), Bin=inn, TaxNumber=kpp',
ok: cpField(ctx.orgId, ro, 'Type') === '1' && cpField(ctx.orgId, ro, 'Bin') === '123456789012' && cpField(ctx.orgId, ro, 'TaxNumber') === 'KPP001',
detail: `Type=${cpField(ctx.orgId, ro, 'Type')} Bin=${cpField(ctx.orgId, ro, 'Bin')} Tax=${cpField(ctx.orgId, ro, 'TaxNumber')}` })
check(step, { kind: 'db', description: 'Ромашка: LegalName=legalTitle, Address=actualAddress, Notes=description',
ok: cpField(ctx.orgId, ro, 'LegalName') === `Товарищество Ромашка ${TS}` && cpField(ctx.orgId, ro, 'Address') === 'Алматы Абая 1' && cpField(ctx.orgId, ro, 'Notes') === 'поставщик овощей',
detail: `Legal=${cpField(ctx.orgId, ro, 'LegalName')} Addr=${cpField(ctx.orgId, ro, 'Address')}` })
// Маппинг ИП Иванов (entrepreneur → Individual=2, Address=legalAddress fallback).
const iv = `ИП Иванов ${TS}`
check(step, { kind: 'db', description: 'Иванов: Type=2(Individual), Address=legalAddress (fallback)',
ok: cpField(ctx.orgId, iv, 'Type') === '2' && cpField(ctx.orgId, iv, 'Address') === 'Астана Победы 5',
detail: `Type=${cpField(ctx.orgId, iv, 'Type')} Addr=${cpField(ctx.orgId, iv, 'Address')}` })
if (cpCount(ctx.orgId) !== 2) report.bug({ step: '02', severity: 'high', title: 'Импортировано не 2 контрагента', detail: `count=${cpCount(ctx.orgId)}` })
}
export async function step03_import_counterparties_idempotent({ ctx, step, report }: StepCtx) {
if (!ctx.orgId || !mock) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken!)
const r = await api.post('/api/admin/moysklad/import-counterparties', { overwriteExisting: false })
const job = await waitJob(api, r.data.jobId)
check(step, { kind: 'api', description: 'job Succeeded', ok: job?.status === 'Succeeded', detail: `status=${job?.status}` })
check(step, { kind: 'api', description: 'Skipped=2, Created=0 (идемпотентность по имени)',
ok: job?.skipped === 2 && job?.created === 0, detail: `created=${job?.created} skipped=${job?.skipped}` })
check(step, { kind: 'db', description: 'Дублей нет — в БД по-прежнему 2', ok: cpCount(ctx.orgId) === 2, detail: `count=${cpCount(ctx.orgId)}` })
if (cpCount(ctx.orgId) !== 2) report.bug({ step: '03', severity: 'critical', title: 'Повторный импорт породил дубли контрагентов', detail: `count=${cpCount(ctx.orgId)}` })
}
export async function step04_import_counterparties_overwrite({ ctx, step, report }: StepCtx) {
if (!ctx.orgId || !mock) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken!)
// Меняем телефоны в источнике и импортируем с overwrite=true.
mock.setData(buildDataset(ctx, '99'))
const r = await api.post('/api/admin/moysklad/import-counterparties', { overwriteExisting: true })
const job = await waitJob(api, r.data.jobId)
check(step, { kind: 'api', description: 'job Succeeded', ok: job?.status === 'Succeeded', detail: `status=${job?.status}` })
check(step, { kind: 'api', description: 'Updated=2, Created=0',
ok: job?.updated === 2 && job?.created === 0, detail: `created=${job?.created} updated=${job?.updated} skipped=${job?.skipped}` })
check(step, { kind: 'db', description: 'Телефон Ромашки обновлён на новый',
ok: cpField(ctx.orgId, `ТОО Ромашка ${TS}`, 'Phone') === '+77011112299',
detail: `phone=${cpField(ctx.orgId, `ТОО Ромашка ${TS}`, 'Phone')}` })
check(step, { kind: 'db', description: 'Кол-во не выросло (обновление, не вставка)', ok: cpCount(ctx.orgId) === 2, detail: `count=${cpCount(ctx.orgId)}` })
// Возвращаем исходные данные, чтобы дальнейшие шаги были предсказуемы.
mock.setData(buildDataset(ctx))
}
export async function step05_import_products_create({ ctx, step, report }: StepCtx) {
if (!ctx.orgId || !mock) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken!)
const r = await api.post('/api/admin/moysklad/import-products', { overwriteExisting: false })
check(step, { kind: 'api', description: 'import-products → jobId', ok: r.status === 200 && !!r.data.jobId, detail: `status=${r.status}` })
if (!r.data?.jobId) return
const job = await waitJob(api, r.data.jobId)
check(step, { kind: 'api', description: 'job Succeeded', ok: job?.status === 'Succeeded', detail: `status=${job?.status} msg=${job?.message} errors=${asString(job?.errors)}` })
check(step, { kind: 'api', description: 'Created=1, GroupsCreated>=1',
ok: job?.created === 1 && (job?.groupsCreated ?? 0) >= 1, detail: `created=${job?.created} groups=${job?.groupsCreated}` })
const art = `SUG-${TS}`
const pid = q1(`SELECT "Id" FROM products WHERE "OrganizationId"='${ctx.orgId}' AND "Article"='${art}'`)
check(step, { kind: 'db', description: 'Товар создан с артикулом из MoySklad', ok: !!pid, detail: `id=${pid}` })
if (!pid) { report.bug({ step: '05', severity: 'high', title: 'Товар не импортирован', detail: `errors=${asString(job?.errors)}` }); return }
const vat = q1(`SELECT "Vat" FROM products WHERE "Id"='${pid}'`)
const pack = q1(`SELECT "Packaging" FROM products WHERE "Id"='${pid}'`)
const marked = q1(`SELECT "IsMarked" FROM products WHERE "Id"='${pid}'`)
const ref = q1(`SELECT "ReferencePrice" FROM products WHERE "Id"='${pid}'`)
check(step, { kind: 'db', description: 'Vat=12, Packaging=1(Piece, weighed=false), IsMarked=false',
ok: Number(vat) === 12 && pack === '1' && (marked === 'f' || marked === 'false'), detail: `vat=${vat} pack=${pack} marked=${marked}` })
check(step, { kind: 'db', description: 'ReferencePrice = buyPrice/100 = 250', ok: Number(ref) === 250, detail: `ref=${ref}` })
// Группа из productFolder.
const grpName = q1(`SELECT g."Name" FROM products p JOIN product_groups g ON g."Id"=p."ProductGroupId" WHERE p."Id"='${pid}'`)
check(step, { kind: 'db', description: 'Группа = productFolder «Бакалея»', ok: grpName === `Бакалея ${TS}`, detail: `group=${grpName}` })
// Страна по имени.
const country = q1(`SELECT "CountryOfOriginId" FROM products WHERE "Id"='${pid}'`)
check(step, { kind: 'db', description: 'CountryOfOrigin сопоставлена по имени',
ok: !!country && country === ctx.countryId, detail: `country=${country} expected=${ctx.countryId}` })
// Розничная цена = salePrice «Розничная» / 100 = 320.
const price = q1(`SELECT "Amount" FROM product_prices WHERE "ProductId"='${pid}'`)
check(step, { kind: 'db', description: 'Розничная цена = 320 (salePrice «Розничная»/100)', ok: Number(price) === 320, detail: `amount=${price}` })
// Штрихкод.
const bc = q1(`SELECT "Code" FROM product_barcodes WHERE "ProductId"='${pid}'`)
check(step, { kind: 'db', description: 'Штрихкод ean13 импортирован', ok: bc === ctx.ean, detail: `bc=${bc} expected=${ctx.ean}` })
}
export async function step06_import_products_idempotent({ ctx, step, report }: StepCtx) {
if (!ctx.orgId || !mock) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken!)
const before = prodCount(ctx.orgId)
const r = await api.post('/api/admin/moysklad/import-products', { overwriteExisting: false })
const job = await waitJob(api, r.data.jobId)
check(step, { kind: 'api', description: 'job Succeeded', ok: job?.status === 'Succeeded', detail: `status=${job?.status}` })
check(step, { kind: 'api', description: 'Skipped=1, Created=0 (идемпотентность по артикулу)',
ok: job?.skipped === 1 && job?.created === 0, detail: `created=${job?.created} skipped=${job?.skipped}` })
check(step, { kind: 'db', description: 'Кол-во товаров не изменилось (дублей нет)',
ok: prodCount(ctx.orgId) === before, detail: `before=${before} after=${prodCount(ctx.orgId)}` })
if (prodCount(ctx.orgId) !== before) report.bug({ step: '06', severity: 'critical', title: 'Повторный импорт породил дубль товара', detail: `before=${before} after=${prodCount(ctx.orgId)}` })
}
export async function step07_cleanup({ step }: StepCtx) {
if (mock) { await mock.close(); mock = undefined }
check(step, { kind: 'api', description: 'Mock MoySklad остановлен', ok: true })
}