- 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>
288 lines
16 KiB
TypeScript
288 lines
16 KiB
TypeScript
/**
|
||
* 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) })
|
||
}
|