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>
This commit is contained in:
nns 2026-05-26 11:27:16 +05:00
parent e78e921dd2
commit c7ecc39590
3 changed files with 392 additions and 0 deletions

View file

@ -0,0 +1,91 @@
/**
* Минимальный mock MoySklad JSON-API 1.2 для e2e-теста импорта.
*
* Отдаёт ровно те эндпоинты, которые дёргает MoySkladClient:
* GET entity/organization для test-connection (WhoAmI)
* GET entity/counterparty список контрагентов
* GET entity/product список товаров
* GET entity/productfolder группы товаров
*
* Формы JSON повторяют реальные поля API (см. MoySkladDtos.cs): name,
* legalTitle, companyType, inn, kpp, phone, email, actual/legalAddress,
* description, archived; product.name/article/code/weighed/vat/vatEnabled/
* trackingType/barcodes/salePrices/buyPrice/productFolder/country. Значения в
* деньгах в minor units (как у MoySklad: клиент делит на 100).
*
* Пагинация: данных мало (<1000), поэтому клиент забирает всё одной страницей.
* Запрос с filter=archived=true отдаём пустым (все наши записи активны).
*
* Данные мутабельны (setData) между прогонами импорта меняем их, чтобы
* проверить overwrite/идемпотентность.
*/
import { createServer, type Server } from 'node:http'
export interface MsDataset {
organization: { name: string; inn: string }
counterparties: Record<string, unknown>[]
products: Record<string, unknown>[]
folders: Record<string, unknown>[]
}
export interface MockHandle {
baseUrl: string
setData: (d: MsDataset) => void
requestCount: () => number
close: () => Promise<void>
}
const BASE_PATH = '/api/remap/1.2/'
export function startMoySkladMock(port: number): Promise<MockHandle> {
let data: MsDataset = { organization: { name: 'Mock Org', inn: '000000000000' }, counterparties: [], products: [], folders: [] }
let requests = 0
const listBody = (rows: Record<string, unknown>[]) => JSON.stringify({
meta: { size: rows.length, limit: 1000, offset: 0, href: 'mock' },
rows,
})
const server = createServer((req, res) => {
requests++
const url = req.url ?? ''
const archivedOnly = url.includes('archived=true')
const path = url.split('?')[0]
const send = (rows: Record<string, unknown>[]) => {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(listBody(archivedOnly ? [] : rows))
}
if (!path.startsWith(BASE_PATH)) {
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ errors: [{ error: 'not found', code: 0 }] }))
return
}
const entity = path.slice(BASE_PATH.length)
switch (entity) {
case 'entity/organization':
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(listBody([data.organization as Record<string, unknown>]))
return
case 'entity/counterparty': return send(data.counterparties)
case 'entity/product': return send(data.products)
case 'entity/productfolder': return send(data.folders)
default:
res.writeHead(404, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ errors: [{ error: `unhandled ${entity}`, code: 0 }] }))
}
})
return new Promise((resolve) => {
server.listen(port, '127.0.0.1', () => {
resolve({
baseUrl: `http://127.0.0.1:${port}${BASE_PATH}`,
setData: (d) => { data = d },
requestCount: () => requests,
close: () => new Promise((r) => server.close(() => r())),
})
})
})
}

View file

@ -0,0 +1,271 @@
/**
* 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 })
}

View file

@ -0,0 +1,30 @@
name: moysklad-import
description: |
Импорт из МойСклад (ТЗ 2.10): сохранение/маскирование токена, test-connection,
импорт контрагентов и товаров через фоновый job, идемпотентность повторного
импорта (overwrite=false → Skipped), обновление по ключу (overwrite=true →
Updated), и корректность маппинга полей MoySklad → доменные сущности.
MoySkladClient наведён на локальный mock-сервер (MoySklad:BaseUrl), который
отдаёт JSON в формате реального API remap 1.2 (поля сверены с MoySkladDtos /
dev.moysklad.ru). Так проверяем именно нашу логику импорта, не дёргая прод.
preconditions:
reset_db: true
smoke_login_super_admin: true
steps:
- id: step01_bootstrap_and_connect
title: "Орг + mock MoySklad + сохранение токена (маскирование) + test-connection 200"
- id: step02_import_counterparties_create
title: "Импорт контрагентов: job Succeeded, Created=2, маппинг полей верен"
- id: step03_import_counterparties_idempotent
title: "Повторный импорт (overwrite=false): Skipped=2, дублей нет"
- id: step04_import_counterparties_overwrite
title: "Импорт overwrite=true с изменёнными данными: Updated=2, поля обновлены"
- id: step05_import_products_create
title: "Импорт товаров: Created=1, группа создана, маппинг (артикул/НДС/цена/штрихкод/группа/страна)"
- id: step06_import_products_idempotent
title: "Повторный импорт товаров (overwrite=false): Skipped=1, дублей нет"
- id: step07_cleanup
title: "Остановка mock-сервера"