food-market/tests/e2e/scenarios/catalog-edge.steps.ts
nns 9f0f071193 test(e2e): scenarios auth-edge, catalog-edge, stock-invariant-deep
- auth-edge (10 шагов): refresh rotation/redemption, подделка JWT,
  деактивированный user, архивная орг, повторный/orphan signup.
- catalog-edge (12 шагов): валидация товара, дубль артикула, удаление
  групп/единиц/системных типов цен с зависимостями, FK-guard контрагента.
- stock-invariant-deep (10 шагов): инвариант Stock == SUM(StockMovement)
  через post/unpost/repost и конкурентные продажи.

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

288 lines
16 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 для catalog-edge.
*
* Краевые проверки CRUD справочников:
* - валидация пустых/отрицательных/слишком длинных полей,
* - циклические ссылки в иерархии,
* - FK-защита (нельзя удалить используемое),
* - дубликаты с уникальными ограничениями.
*/
import { login, makeClient } from '../lib/api.js'
import { generateEan13 } from '../lib/barcode.js'
import type { CheckResult, Step, Report } from '../lib/report.js'
const TS = Date.now()
interface Ctx {
apiOnly: boolean
superAdminToken?: string
adminToken?: string
orgId?: string
unitId?: string
groupId?: string
rootGroupId?: string
currencyId?: string
retailPriceTypeId?: string
storeId?: string
retailPointId?: string
supplierId?: string
productId?: string
}
interface StepCtx { ctx: Ctx; step: Step; report: Report }
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) }
}
async function ensureSuperAdmin(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_bootstrap({ ctx, step, report }: StepCtx) {
const sa = await ensureSuperAdmin(ctx)
const orgRes = await makeClient(sa).post('/api/super-admin/organizations', {
org: {
name: `Catalog Edge ${TS}`, countryCode: 'KZ',
bin: null, address: null, phone: null, email: null,
defaultCurrencyId: null, accountOwnerUserId: null,
},
adminLastName: 'Edge', adminFirstName: 'Cat',
adminEmail: `cat-edge-${TS}@example.kz`, adminPosition: null,
})
if (orgRes.status !== 200) { report.bug({ step: '01', severity: 'critical',
title: 'Не создалась орг', detail: asString(orgRes.data) }); return }
ctx.orgId = orgRes.data.organization.id
const adminSess = await login(orgRes.data.adminEmail, orgRes.data.adminTempPassword)
ctx.adminToken = adminSess.accessToken
const api = makeClient(ctx.adminToken)
const units = await api.get('/api/catalog/units-of-measure')
ctx.unitId = units.data?.items?.[0]?.id ?? units.data?.[0]?.id
const grps = await api.get('/api/catalog/product-groups?pageSize=10')
ctx.rootGroupId = grps.data?.items?.[0]?.id
if (!ctx.rootGroupId) {
const g = await api.post('/api/catalog/product-groups', { name: 'Root', parentId: null })
ctx.rootGroupId = g.data?.id
}
ctx.groupId = ctx.rootGroupId
const cur = await api.get('/api/catalog/currencies?pageSize=200')
ctx.currencyId = cur.data?.items?.find((c: { code: string }) => c.code === 'KZT')?.id
const pt = await api.get('/api/catalog/price-types')
ctx.retailPriceTypeId = pt.data?.items?.find((p: { isRetail: boolean }) => p.isRetail)?.id
const stores = await api.get('/api/catalog/stores?pageSize=10')
ctx.storeId = stores.data?.items?.[0]?.id
const rps = await api.get('/api/catalog/retail-points?pageSize=10')
ctx.retailPointId = rps.data?.items?.[0]?.id
check(step, { kind: 'api', description: 'Bootstrap lookups получены',
ok: !!ctx.unitId && !!ctx.groupId && !!ctx.currencyId && !!ctx.retailPriceTypeId && !!ctx.storeId })
}
export async function step02_empty_product_name_rejected({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
const r = await api.post('/api/catalog/products', {
name: '', unitOfMeasureId: ctx.unitId, productGroupId: ctx.groupId,
vat: 12, vatEnabled: true,
barcodes: [{ code: generateEan13(), type: 1, isPrimary: true }],
prices: [{ priceTypeId: ctx.retailPriceTypeId, currencyId: ctx.currencyId, amount: 100 }],
})
const ok = r.status === 400
check(step, { kind: 'api', description: 'POST product с пустым name → 400',
ok, detail: `status=${r.status} ${asString(r.data).slice(0, 120)}` })
if (!ok && r.status >= 200 && r.status < 300) report.bug({ step: '02', severity: 'medium',
title: 'Создан product с пустым name', detail: asString(r.data) })
}
export async function step03_negative_price_rejected({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
const r = await api.post('/api/catalog/products', {
name: 'NegPrice', unitOfMeasureId: ctx.unitId, productGroupId: ctx.groupId,
vat: 12, vatEnabled: true,
barcodes: [{ code: generateEan13(), type: 1, isPrimary: true }],
prices: [{ priceTypeId: ctx.retailPriceTypeId, currencyId: ctx.currencyId, amount: -100 }],
})
const ok = r.status === 400
check(step, { kind: 'api', description: 'POST product с amount=-100 → 400',
ok, detail: `status=${r.status} ${asString(r.data).slice(0, 120)}` })
if (!ok && r.status >= 200 && r.status < 300) report.bug({ step: '03', severity: 'high',
title: 'Создан product с отрицательной ценой',
detail: 'Range(0, 1e10) на ProductPriceInput.Amount должен это отбить.' })
}
export async function step04_oversized_name_truncated_or_rejected({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
const longName = 'A'.repeat(600) // products.Name варчар 500
const r = await api.post('/api/catalog/products', {
name: longName, unitOfMeasureId: ctx.unitId, productGroupId: ctx.groupId,
vat: 12, vatEnabled: true,
barcodes: [{ code: generateEan13(), type: 1, isPrimary: true }],
prices: [{ priceTypeId: ctx.retailPriceTypeId, currencyId: ctx.currencyId, amount: 100 }],
})
// Идеально — 400 (валидация на бэке). Допустимо 500 если БД отбивает ALTER.
const ok = r.status >= 400 && r.status < 500
check(step, { kind: 'api', description: 'POST product с name=600 chars → 4xx',
ok, detail: `status=${r.status} ${asString(r.data).slice(0, 100)}` })
if (r.status >= 500) report.gap('Слишком длинный Product.Name возвращает 5xx — должно быть 400 с понятным сообщением.')
if (r.status >= 200 && r.status < 300) report.bug({ step: '04', severity: 'medium',
title: 'Принят name > 500 символов', detail: asString(r.data) })
}
export async function step05_duplicate_product_article({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
const article = `ART-${TS}`
const a = await api.post('/api/catalog/products', {
name: 'First', article, unitOfMeasureId: ctx.unitId, productGroupId: ctx.groupId,
vat: 12, vatEnabled: true,
barcodes: [{ code: generateEan13(), type: 1, isPrimary: true }],
prices: [{ priceTypeId: ctx.retailPriceTypeId, currencyId: ctx.currencyId, amount: 100 }],
})
ctx.productId = a.data?.id
check(step, { kind: 'api', description: 'POST 1-й product с article OK',
ok: a.status === 201, detail: `status=${a.status}` })
const b = await api.post('/api/catalog/products', {
name: 'Second', article, unitOfMeasureId: ctx.unitId, productGroupId: ctx.groupId,
vat: 12, vatEnabled: true,
barcodes: [{ code: generateEan13(), type: 1, isPrimary: true }],
prices: [{ priceTypeId: ctx.retailPriceTypeId, currencyId: ctx.currencyId, amount: 100 }],
})
// Если article уникален per-org — должен 4xx. Если нет — 201, и это надо отметить.
if (b.status === 201) {
report.gap('Article у Product не уникален per-org — два товара могут иметь одинаковый артикул, путаница в учёте.')
check(step, { kind: 'api', description: 'POST 2-й product с тем же article (gap)',
ok: true, detail: 'не запрещено сервером' })
} else {
check(step, { kind: 'api', description: 'POST 2-й product с тем же article → 4xx',
ok: b.status >= 400 && b.status < 500, detail: `status=${b.status} ${asString(b.data).slice(0, 100)}` })
}
}
export async function step06_self_parent_group_rejected({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.rootGroupId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
const r = await api.put(`/api/catalog/product-groups/${ctx.rootGroupId}`, {
name: 'SelfParent', parentId: ctx.rootGroupId,
})
const ok = r.status >= 400 && r.status < 500
check(step, { kind: 'api', description: 'PUT product-group parentId=self → 4xx',
ok, detail: `status=${r.status} ${asString(r.data).slice(0, 100)}` })
if (!ok && r.status >= 200 && r.status < 300) report.bug({ step: '06', severity: 'high',
title: 'ProductGroup может ссылаться на саму себя как parent — циклическая иерархия',
detail: 'Это сломает рендеринг дерева и Path-кэш.' })
}
export async function step07_delete_group_with_children({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.rootGroupId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
// Создадим дочернюю группу.
const child = await api.post('/api/catalog/product-groups', { name: `Child ${TS}`, parentId: ctx.rootGroupId })
if (child.status !== 201) { step.notes.push(`Не создана child: ${child.status}`); return }
const r = await api.delete(`/api/catalog/product-groups/${ctx.rootGroupId}`)
const ok = r.status === 409 || r.status === 400
check(step, { kind: 'api', description: 'DELETE group с детьми → 4xx',
ok, detail: `status=${r.status} ${asString(r.data).slice(0, 100)}` })
if (!ok && r.status >= 200 && r.status < 300) report.bug({ step: '07', severity: 'critical',
title: 'DELETE group с детьми прошёл — orphan child group',
detail: 'FK-restrict в БД и явная проверка в контроллере должны блокировать.' })
}
export async function step08_delete_group_with_products({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.groupId || !ctx.productId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
// ProductId уже в этой группе из step05.
const r = await api.delete(`/api/catalog/product-groups/${ctx.groupId}`)
const ok = r.status === 409 || r.status === 400
check(step, { kind: 'api', description: 'DELETE group с продуктами → 4xx',
ok, detail: `status=${r.status} ${asString(r.data).slice(0, 100)}` })
if (!ok && r.status >= 200 && r.status < 300) report.bug({ step: '08', severity: 'critical',
title: 'DELETE group с привязанными product — orphan products',
detail: asString(r.data) })
}
export async function step09_delete_unit_with_products({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.unitId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
const r = await api.delete(`/api/catalog/units-of-measure/${ctx.unitId}/enable`)
const ok = r.status === 409 || r.status === 400
check(step, { kind: 'api', description: 'DELETE enable у unit с продуктами → 4xx',
ok, detail: `status=${r.status} ${asString(r.data).slice(0, 150)}` })
if (!ok && r.status >= 200 && r.status < 300) report.bug({ step: '09', severity: 'high',
title: 'DELETE enable у unit с продуктами прошёл — broken FK',
detail: asString(r.data) })
}
export async function step10_delete_system_price_type({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
const list = await api.get('/api/catalog/price-types')
const sys = list.data?.items?.find((p: { isSystem: boolean }) => p.isSystem)
if (!sys) { step.notes.push('Нет системных PriceType в орге'); return }
const r = await api.delete(`/api/catalog/price-types/${sys.id}`)
const ok = r.status === 409 || r.status === 400 || r.status === 403
check(step, { kind: 'api', description: 'DELETE системной PriceType → 4xx',
ok, detail: `status=${r.status} ${asString(r.data).slice(0, 100)}` })
if (!ok && r.status >= 200 && r.status < 300) report.bug({ step: '10', severity: 'high',
title: 'DELETE PriceType.IsSystem прошёл — системный справочник не защищён',
detail: asString(r.data) })
}
export async function step11_second_retail_price_type({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
// Контракт: POST IsRetail=true допустимо, но СЕМАНТИЧЕСКИ сервер переносит
// флаг — старый IsRetail сбрасывается в false (см. PriceTypesController.Create
// ExecuteUpdateAsync(IsRetail=false)). Проверяем что после операции ровно
// один IsRetail остался — иначе либо 4xx, либо это баг.
const r = await api.post('/api/catalog/price-types', {
name: 'Вторая розничная', isRequired: false, isRetail: true, sortOrder: 99,
})
check(step, { kind: 'api', description: 'POST второй IsRetail PriceType — 201 (флаг перенесён) или 4xx',
ok: r.status === 201 || (r.status >= 400 && r.status < 500), detail: `status=${r.status}` })
const list = await api.get('/api/catalog/price-types?pageSize=200')
const retails = (list.data?.items ?? []).filter((p: { isRetail: boolean }) => p.isRetail)
check(step, { kind: 'api', description: 'IsRetail=true ровно у одного PriceType (uniqueness)',
ok: retails.length === 1, detail: `count=${retails.length}` })
if (retails.length !== 1) report.bug({ step: '11', severity: 'high',
title: 'Нарушена уникальность IsRetail (несколько розничных типов цен на орг)',
detail: `Найдено ${retails.length} типов с IsRetail=true.` })
}
export async function step12_delete_counterparty_with_supply({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.productId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
// Создаём counterparty + supply на него.
const cp = await api.post('/api/catalog/counterparties', {
name: `Cat Edge Supplier ${TS}`, type: 1, bin: '111122223333', phone: '+77001112233',
})
if (cp.status !== 201) { step.notes.push(`counterparty create: ${cp.status}`); return }
ctx.supplierId = cp.data.id
const sup = await api.post('/api/purchases/supplies', {
storeId: ctx.storeId, supplierId: ctx.supplierId, currencyId: ctx.currencyId,
date: new Date().toISOString(),
lines: [{ productId: ctx.productId, quantity: 1, unitPrice: 50 }],
})
if (sup.status !== 201) { step.notes.push(`supply create: ${sup.status}`); return }
// Удалить counterparty — должен быть 409 (FK).
const del = await api.delete(`/api/catalog/counterparties/${ctx.supplierId}`)
const ok = del.status === 409 || del.status === 400
check(step, { kind: 'api', description: 'DELETE counterparty с Supply → 4xx',
ok, detail: `status=${del.status} ${asString(del.data).slice(0, 120)}` })
if (!ok && del.status >= 200 && del.status < 300) report.bug({ step: '12', severity: 'critical',
title: 'DELETE counterparty с привязанным Supply прошёл — orphan документы',
detail: asString(del.data) })
}