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:
parent
e78e921dd2
commit
c7ecc39590
91
tests/e2e/lib/moysklad-mock.ts
Normal file
91
tests/e2e/lib/moysklad-mock.ts
Normal 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())),
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
271
tests/e2e/scenarios/moysklad-import.steps.ts
Normal file
271
tests/e2e/scenarios/moysklad-import.steps.ts
Normal 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 })
|
||||||
|
}
|
||||||
30
tests/e2e/scenarios/moysklad-import.yml
Normal file
30
tests/e2e/scenarios/moysklad-import.yml
Normal 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-сервера"
|
||||||
Loading…
Reference in a new issue