food-market/tests/e2e/scenarios/documents-edge.steps.ts
nns 17a454cce5 test(e2e): scenario documents-edge — критичные edge-кейсы посту
10 шагов покрывают самую опасную зону системы (потеря денег/остатков):

1. Bootstrap: орг + admin + product + supply (10 шт по 100 KZT).
2. Supply.Post → stock=10 invariant.
3. RetailSale qty=15 (>stock 10) → POST /post → 409 «Недостаточно».
4. После заблокированного post: stock=10 + Stock == Σ StockMovement.
5. RetailSale PaidCash+PaidCard < Total → 4xx (валидация платежа).
6. PUT проведённой Supply → 409.
7. DELETE проведённой Supply → 409.
8. После Sale qty=5: unpost Supply qty=10 → 409 (stock уйдёт в минус).
9. Дубль штрихкода в одной орге → 4xx.
10. Тот же штрихкод в другой орге → 201 (per-tenant unique).

Запуск: `bash tests/e2e/run.sh documents-edge --api-only`.
Все 10 шагов зелёные после фиксов RetailSale.Post + Supply.Unpost.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-23 12:33:51 +05:00

352 lines
18 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 для documents-edge. Самые опасные edge-кейсы документов:
* - oversell-protection (stock не уходит в минус),
* - immutability проведённых документов,
* - валидация платежа,
* - уникальность штрихкодов per-tenant.
*
* Любая утечка/обход — критический баг.
*/
import { login, makeClient } from '../lib/api.js'
import { countRows, psql } from '../lib/db.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
orgId?: string
adminToken?: string
unitId?: string
groupId?: string
currencyId?: string
retailPriceTypeId?: string
storeId?: string
retailPointId?: string
supplierId?: string
productId?: string
productBarcode?: string
supplyId?: 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, 220) } 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 saApi = makeClient(sa)
// 1. Создаём орг + админа.
const orgRes = await saApi.post('/api/super-admin/organizations', {
org: {
name: `Edge Shop ${TS}`, countryCode: 'KZ',
bin: null, address: null, phone: null, email: null,
defaultCurrencyId: null, accountOwnerUserId: null,
},
adminLastName: 'Edge', adminFirstName: 'Admin',
adminEmail: `edge-${TS}@example.kz`, adminPosition: 'Директор',
})
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
check(step, { kind: 'api', description: 'Орг + админ созданы', ok: true,
detail: `org=${ctx.orgId}` })
const api = makeClient(ctx.adminToken)
// 2. Lookups.
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.groupId = grps.data?.items?.[0]?.id
if (!ctx.groupId) {
const g = await api.post('/api/catalog/product-groups', { name: 'Group', parentId: null })
ctx.groupId = g.data?.id
}
const currencies = await api.get('/api/catalog/currencies?pageSize=200')
ctx.currencyId = currencies.data?.items?.find((c: { code: string }) => c.code === 'KZT')?.id
const priceTypes = await api.get('/api/catalog/price-types')
ctx.retailPriceTypeId = priceTypes.data?.items?.find((p: { isRetail: boolean }) => p.isRetail)?.id
// 3. Counterparty (поставщик).
const cp = await api.post('/api/catalog/counterparties', {
name: 'Edge Supplier', type: 1, bin: '012345678901', phone: '+77001234567',
})
ctx.supplierId = cp.data?.id
check(step, { kind: 'api', description: 'Counterparty создан', ok: cp.status === 201 })
// 4. Store + RetailPoint из bootstrap.
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
// 5. Product.
ctx.productBarcode = generateEan13()
const prod = await api.post('/api/catalog/products', {
name: `Edge Product ${TS}`,
unitOfMeasureId: ctx.unitId, productGroupId: ctx.groupId,
vat: 12, vatEnabled: true,
barcodes: [{ code: ctx.productBarcode, type: 1, isPrimary: true }],
prices: [{ priceTypeId: ctx.retailPriceTypeId, currencyId: ctx.currencyId, amount: 200 }],
})
ctx.productId = prod.data?.id
check(step, { kind: 'api', description: 'Product создан', ok: prod.status === 201,
detail: prod.status === 201 ? ctx.productId : asString(prod.data) })
// 6. Supply Draft (10 шт по 100 KZT).
const supplyRes = await api.post('/api/purchases/supplies', {
storeId: ctx.storeId, supplierId: ctx.supplierId, currencyId: ctx.currencyId,
date: new Date().toISOString(),
lines: [{ productId: ctx.productId, quantity: 10, unitPrice: 100 }],
})
ctx.supplyId = supplyRes.data?.id
check(step, { kind: 'api', description: 'Supply Draft создана', ok: supplyRes.status === 201,
detail: supplyRes.status === 201 ? ctx.supplyId : asString(supplyRes.data) })
}
export async function step02_post_supply_stock_10({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.supplyId || !ctx.productId || !ctx.storeId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
const post = await api.post(`/api/purchases/supplies/${ctx.supplyId}/post`)
check(step, { kind: 'api', description: 'Supply.Post → 200/204',
ok: post.status === 200 || post.status === 204, detail: `actual=${post.status}` })
// Проверяем что stock=10
const stock = await api.get(`/api/inventory/stock?productId=${ctx.productId}&storeId=${ctx.storeId}`)
const row = (stock.data?.items ?? []).find((s: { productId: string }) => s.productId === ctx.productId)
const qty = Number(row?.quantity ?? 0)
check(step, { kind: 'db', description: 'Stock.Quantity == 10',
ok: qty === 10, detail: `qty=${qty}` })
if (qty !== 10) report.bug({ step: '02', severity: 'high',
title: 'Stock после Supply.Post не равен 10', detail: `получено qty=${qty}` })
}
export async function step03_oversell_blocked({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.productId || !ctx.storeId || !ctx.retailPointId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
// Создаём чек с qty=15 (превышает stock=10).
const sale = await api.post('/api/sales/retail', {
storeId: ctx.storeId, retailPointId: ctx.retailPointId,
currencyId: ctx.currencyId,
date: new Date().toISOString(),
lines: [{ productId: ctx.productId, quantity: 15, unitPrice: 200, vatPercent: 12 }],
paidCash: 3000, paidCard: 0,
})
check(step, { kind: 'api', description: 'POST RetailSale Draft (qty=15)',
ok: sale.status === 201, detail: `actual=${sale.status} ${asString(sale.data).slice(0, 120)}` })
if (sale.status !== 201) return
const post = await api.post(`/api/sales/retail/${sale.data.id}/post`)
const ok = post.status === 409
check(step, { kind: 'api', description: 'POST /post → 409 (oversell)',
ok, detail: `actual=${post.status} ${asString(post.data).slice(0, 120)}` })
if (!ok && post.status >= 200 && post.status < 300) {
report.bug({ step: '03', severity: 'critical',
title: 'OVERSELL: stock уходит в минус — продажа qty>остаток прошла',
detail: `Post вернул ${post.status}. Нужен server-side guard: SUM(line.qty) <= stock.quantity per (product,store).` })
}
}
export async function step04_oversell_stock_unchanged({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.productId || !ctx.storeId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
const stock = await api.get(`/api/inventory/stock?productId=${ctx.productId}&storeId=${ctx.storeId}`)
const row = (stock.data?.items ?? []).find((s: { productId: string }) => s.productId === ctx.productId)
const qty = Number(row?.quantity ?? 0)
check(step, { kind: 'db', description: 'Stock остался 10 после заблокированного post',
ok: qty === 10, detail: `qty=${qty}` })
// Также: invariant Stock == Σ Movement
if (ctx.productId && ctx.storeId) {
const movSum = psql(
`SELECT COALESCE(SUM("Quantity"),0)::text FROM stock_movements
WHERE "ProductId"='${ctx.productId}' AND "StoreId"='${ctx.storeId}'`,
).trim()
const sumNum = Number(movSum.split('\n')[0] || '0')
check(step, { kind: 'db', description: 'Stock == Σ StockMovement (invariant)',
ok: sumNum === qty, detail: `sum=${sumNum} qty=${qty}` })
}
}
export async function step05_payment_mismatch_blocked({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.productId || !ctx.storeId || !ctx.retailPointId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
const sale = await api.post('/api/sales/retail', {
storeId: ctx.storeId, retailPointId: ctx.retailPointId, currencyId: ctx.currencyId,
date: new Date().toISOString(),
lines: [{ productId: ctx.productId, quantity: 2, unitPrice: 200, vatPercent: 12 }],
// Total = 400, но оплачено только 300 — должен отвергаться на post.
paidCash: 100, paidCard: 200,
})
if (sale.status !== 201) {
check(step, { kind: 'api', description: 'Draft создан', ok: false, detail: asString(sale.data) })
return
}
const post = await api.post(`/api/sales/retail/${sale.data.id}/post`)
const ok = post.status >= 400 && post.status < 500
check(step, { kind: 'api', description: 'Платёж ≠ Total → 4xx на post',
ok, detail: `actual=${post.status} ${asString(post.data).slice(0, 120)}` })
if (!ok) report.bug({ step: '05', severity: 'high',
title: 'Проведение чека с неверной суммой платежа разрешено',
detail: `Сумма оплаты 300 при Total=400 — post вернул ${post.status}.` })
}
export async function step06_edit_posted_supply_blocked({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.supplyId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
// Supply уже Posted в step02.
const cur = await api.get(`/api/purchases/supplies/${ctx.supplyId}`)
if (cur.status !== 200) { step.status = 'skip'; return }
const put = await api.put(`/api/purchases/supplies/${ctx.supplyId}`, {
...cur.data,
date: cur.data.date,
lines: (cur.data.lines || []).map((l: { productId: string; quantity: number; unitPrice: number }) =>
({ productId: l.productId, quantity: l.quantity, unitPrice: l.unitPrice + 1 })),
})
const ok = put.status === 409
check(step, { kind: 'api', description: 'PUT проведённой Supply → 409',
ok, detail: `actual=${put.status} ${asString(put.data).slice(0, 100)}` })
if (!ok && put.status >= 200 && put.status < 300) report.bug({ step: '06', severity: 'high',
title: 'Posted Supply можно изменить через PUT', detail: asString(put.data) })
}
export async function step07_delete_posted_supply_blocked({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.supplyId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
const del = await api.delete(`/api/purchases/supplies/${ctx.supplyId}`)
const ok = del.status === 409
check(step, { kind: 'api', description: 'DELETE проведённой Supply → 409',
ok, detail: `actual=${del.status} ${asString(del.data).slice(0, 100)}` })
if (!ok && del.status >= 200 && del.status < 300) report.bug({ step: '07', severity: 'high',
title: 'Posted Supply можно удалить', detail: asString(del.data) })
}
export async function step08_unpost_negative_blocked({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.productId || !ctx.storeId || !ctx.retailPointId || !ctx.supplyId) {
step.status = 'skip'; return
}
const api = makeClient(ctx.adminToken)
// Сначала продаём qty=5 (валидно: stock=10 → станет 5).
const sale = await api.post('/api/sales/retail', {
storeId: ctx.storeId, retailPointId: ctx.retailPointId, currencyId: ctx.currencyId,
date: new Date().toISOString(),
lines: [{ productId: ctx.productId, quantity: 5, unitPrice: 200, vatPercent: 12 }],
paidCash: 1000, paidCard: 0,
})
if (sale.status !== 201) { step.status = 'skip'; step.notes.push(`Draft sale fail: ${asString(sale.data)}`); return }
const postSale = await api.post(`/api/sales/retail/${sale.data.id}/post`)
check(step, { kind: 'api', description: 'Sale qty=5 проведён',
ok: postSale.status === 200 || postSale.status === 204, detail: `actual=${postSale.status}` })
if (postSale.status >= 400) return
// Stock теперь 5. Unpost Supply (qty=10) уведёт stock в -5 → должен 409.
const unpost = await api.post(`/api/purchases/supplies/${ctx.supplyId}/unpost`)
const ok = unpost.status === 409
check(step, { kind: 'api', description: 'Unpost Supply при stock<unpost-qty → 409',
ok, detail: `actual=${unpost.status} ${asString(unpost.data).slice(0, 120)}` })
if (!ok && unpost.status >= 200 && unpost.status < 300) {
report.bug({ step: '08', severity: 'critical',
title: 'Unpost Supply уводит Stock в минус (нет защиты)',
detail: `После Sale qty=5 stock=5. Unpost Supply qty=10 ⇒ stock=-5. Got HTTP ${unpost.status}.` })
} else if (unpost.status >= 400 && unpost.status < 500 && unpost.status !== 409) {
// Сервер тоже отверг, но другим кодом — это semantic gap.
report.gap(`Unpost Supply при отрицательном остатке вернул ${unpost.status} вместо 409. Ожидался Conflict — единый код для бизнес-конфликтов.`)
}
}
export async function step09_barcode_unique_within_org({ ctx, step, report }: StepCtx) {
if (!ctx.adminToken || !ctx.productBarcode || !ctx.unitId || !ctx.groupId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken)
const dup = await api.post('/api/catalog/products', {
name: 'Дубль-штрихкод', unitOfMeasureId: ctx.unitId, productGroupId: ctx.groupId,
vat: 12, vatEnabled: true,
barcodes: [{ code: ctx.productBarcode, type: 1, isPrimary: true }],
prices: [{ priceTypeId: ctx.retailPriceTypeId, currencyId: ctx.currencyId, amount: 300 }],
})
const ok = dup.status === 409 || dup.status === 400
check(step, { kind: 'api', description: 'POST product с тем же barcode → 4xx',
ok, detail: `actual=${dup.status} ${asString(dup.data).slice(0, 120)}` })
if (!ok && dup.status >= 200 && dup.status < 300) report.bug({ step: '09', severity: 'high',
title: 'Дубль штрихкода в одной орге разрешён',
detail: `POST вернул ${dup.status}. Нарушает уникальный индекс per (OrgId, Barcode).` })
}
export async function step10_barcode_per_tenant({ ctx, step, report }: StepCtx) {
if (!ctx.productBarcode) { step.status = 'skip'; return }
// Создаём другую орг и пытаемся использовать тот же штрихкод.
const sa = await ensureSuperAdmin(ctx)
const saApi = makeClient(sa)
const orgRes = await saApi.post('/api/super-admin/organizations', {
org: {
name: `Edge Shop2 ${TS}`, countryCode: 'KZ',
bin: null, address: null, phone: null, email: null,
defaultCurrencyId: null, accountOwnerUserId: null,
},
adminLastName: 'Edge2', adminFirstName: 'Admin',
adminEmail: `edge2-${TS}@example.kz`, adminPosition: null,
})
if (orgRes.status !== 200) { step.status = 'skip'; step.notes.push('Не удалось создать вторую орг'); return }
const adminSess = await login(orgRes.data.adminEmail, orgRes.data.adminTempPassword)
const api2 = makeClient(adminSess.accessToken)
// Lookups для новой орги
const units = await api2.get('/api/catalog/units-of-measure')
const u2 = units.data?.items?.[0]?.id ?? units.data?.[0]?.id
const grps = await api2.get('/api/catalog/product-groups?pageSize=10')
let g2 = grps.data?.items?.[0]?.id
if (!g2) {
const g = await api2.post('/api/catalog/product-groups', { name: 'G2', parentId: null })
g2 = g.data?.id
}
const cur = await api2.get('/api/catalog/currencies?pageSize=200')
const c2 = cur.data?.items?.find((c: { code: string }) => c.code === 'KZT')?.id
const pt = await api2.get('/api/catalog/price-types')
const r2 = pt.data?.items?.find((p: { isRetail: boolean }) => p.isRetail)?.id
const prod = await api2.post('/api/catalog/products', {
name: 'Tenant-2 product (same barcode)',
unitOfMeasureId: u2, productGroupId: g2,
vat: 12, vatEnabled: true,
barcodes: [{ code: ctx.productBarcode, type: 1, isPrimary: true }],
prices: [{ priceTypeId: r2, currencyId: c2, amount: 250 }],
})
check(step, { kind: 'api', description: 'POST product с тем же barcode в другой орге → 201',
ok: prod.status === 201, detail: `actual=${prod.status} ${asString(prod.data).slice(0, 120)}` })
if (prod.status !== 201) report.bug({ step: '10', severity: 'medium',
title: 'Per-tenant уникальность штрихкода не работает',
detail: `Уникальный индекс должен быть на (OrganizationId, Code), сейчас отвергает межтенантное переиспользование. Got ${prod.status}.` })
// Подсчитаем общее число с этим штрихкодом
const totalRows = countRows('product_barcodes', `"Code"='${ctx.productBarcode}'`)
check(step, { kind: 'db', description: 'В product_barcodes 2 записи с этим Code (одна на орг)',
ok: totalRows === 2, detail: `count=${totalRows}` })
}