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>
352 lines
18 KiB
TypeScript
352 lines
18 KiB
TypeScript
/**
|
||
* 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}` })
|
||
}
|