food-market/tests/e2e/scenarios/stock-invariant-deep.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

273 lines
13 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 для stock-invariant-deep.
*
* Главный инвариант розничного учёта: для любого (ProductId, StoreId) сумма
* StockMovement.Quantity точно равна materialized Stock.Quantity. Любое
* расхождение = bug в Supply.Post / RetailSale.Post / Unpost — потеря товара
* или ложные минусовые/завышенные остатки.
*
* Дополнительно проверяем что Serializable-транзакция RetailSale.Post
* отбивает гонку: два кассира одновременно проводят чеки на один и тот же
* остаток — должен пройти ровно один.
*/
import { login, makeClient } from '../lib/api.js'
import { 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
adminToken?: string
productId?: string
storeId?: string
retailPointId?: string
supplierId?: string
currencyId?: string
retailPriceTypeId?: string
supplyA?: string
supplyB?: string
saleA?: string
saleB?: 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
}
function dbQty(productId: string, storeId: string): number {
const out = psql(`SELECT "Quantity" FROM stocks
WHERE "ProductId"='${productId}' AND "StoreId"='${storeId}'`).trim()
return Number((out.split('\n')[0] || '0')) || 0
}
function dbMovementSum(productId: string, storeId: string): number {
const out = psql(`SELECT COALESCE(SUM("Quantity"),0)::text FROM stock_movements
WHERE "ProductId"='${productId}' AND "StoreId"='${storeId}'`).trim()
return Number((out.split('\n')[0] || '0')) || 0
}
function dbMovementCount(productId: string, storeId: string): number {
const out = psql(`SELECT count(*)::text FROM stock_movements
WHERE "ProductId"='${productId}' AND "StoreId"='${storeId}'`).trim()
return Number((out.split('\n')[0] || '0')) || 0
}
function assertInvariant(step: Step, productId: string, storeId: string, expectedQty: number) {
const stock = dbQty(productId, storeId)
const sum = dbMovementSum(productId, storeId)
check(step, { kind: 'db', description: `Stock.Quantity == ${expectedQty}`,
ok: stock === expectedQty, detail: `actual=${stock}` })
check(step, { kind: 'db', description: 'Stock.Quantity == Σ StockMovement (invariant)',
ok: stock === sum, detail: `stock=${stock} sum=${sum}` })
}
// ---------------------------------------------------------------------------
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: `Stock Inv ${TS}`, countryCode: 'KZ',
bin: null, address: null, phone: null, email: null,
defaultCurrencyId: null, accountOwnerUserId: null,
},
adminLastName: 'Stock', adminFirstName: 'Admin',
adminEmail: `stock-inv-${TS}@example.kz`, adminPosition: null,
})
if (orgRes.status !== 200) { report.bug({ step: '01', severity: 'critical',
title: 'Не удалось создать орг', detail: asString(orgRes.data) }); return }
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')
const unitId = units.data?.items?.[0]?.id ?? units.data?.[0]?.id
const grps = await api.get('/api/catalog/product-groups?pageSize=10')
let groupId = grps.data?.items?.[0]?.id
if (!groupId) {
const g = await api.post('/api/catalog/product-groups', { name: 'G', parentId: null })
groupId = g.data?.id
}
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
const cp = await api.post('/api/catalog/counterparties', {
name: `Inv Supplier ${TS}`, type: 1, bin: '987654321012', phone: '+77001112233',
})
ctx.supplierId = cp.data?.id
const prod = await api.post('/api/catalog/products', {
name: `Inv Product ${TS}`, unitOfMeasureId: unitId, productGroupId: groupId,
vat: 12, vatEnabled: true,
barcodes: [{ code: generateEan13(), type: 1, isPrimary: true }],
prices: [{ priceTypeId: ctx.retailPriceTypeId, currencyId: ctx.currencyId, amount: 200 }],
})
ctx.productId = prod.data?.id
check(step, { kind: 'api', description: 'Bootstrap product создан',
ok: prod.status === 201, detail: ctx.productId ?? asString(prod.data) })
// Стартовый стейт.
assertInvariant(step, ctx.productId!, ctx.storeId!, 0)
}
async function postSupply(ctx: Ctx, quantity: number, unitPrice: number): Promise<string | undefined> {
const api = makeClient(ctx.adminToken!)
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, unitPrice }],
})
if (sup.status !== 201) return undefined
const p = await api.post(`/api/purchases/supplies/${sup.data.id}/post`)
if (p.status >= 400) return undefined
return sup.data.id
}
async function postSale(ctx: Ctx, quantity: number, unitPrice: number): Promise<string | undefined> {
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, unitPrice, vatPercent: 12 }],
paidCash: quantity * unitPrice, paidCard: 0,
})
if (sale.status !== 201) return undefined
const p = await api.post(`/api/sales/retail/${sale.data.id}/post`)
if (p.status >= 400) return undefined
return sale.data.id
}
export async function step02_supply_a_qty_20({ ctx, step, report }: StepCtx) {
if (!ctx.productId) { step.status = 'skip'; return }
ctx.supplyA = await postSupply(ctx, 20, 100)
check(step, { kind: 'api', description: 'Supply A qty=20 проведена', ok: !!ctx.supplyA })
assertInvariant(step, ctx.productId, ctx.storeId!, 20)
}
export async function step03_sale_a_qty_5({ ctx, step }: StepCtx) {
if (!ctx.productId) { step.status = 'skip'; return }
ctx.saleA = await postSale(ctx, 5, 200)
check(step, { kind: 'api', description: 'Sale A qty=5 проведена', ok: !!ctx.saleA })
assertInvariant(step, ctx.productId, ctx.storeId!, 15)
}
export async function step04_supply_b_qty_10({ ctx, step }: StepCtx) {
if (!ctx.productId) { step.status = 'skip'; return }
ctx.supplyB = await postSupply(ctx, 10, 120)
check(step, { kind: 'api', description: 'Supply B qty=10 проведена', ok: !!ctx.supplyB })
assertInvariant(step, ctx.productId, ctx.storeId!, 25)
}
export async function step05_sale_b_qty_8({ ctx, step }: StepCtx) {
if (!ctx.productId) { step.status = 'skip'; return }
ctx.saleB = await postSale(ctx, 8, 200)
check(step, { kind: 'api', description: 'Sale B qty=8 проведена', ok: !!ctx.saleB })
assertInvariant(step, ctx.productId, ctx.storeId!, 17)
}
export async function step06_unpost_sale_a({ ctx, step }: StepCtx) {
if (!ctx.saleA) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken!)
const r = await api.post(`/api/sales/retail/${ctx.saleA}/unpost`)
check(step, { kind: 'api', description: 'Unpost Sale A → 200/204',
ok: r.status === 200 || r.status === 204, detail: `status=${r.status}` })
assertInvariant(step, ctx.productId!, ctx.storeId!, 22)
}
export async function step07_repost_sale_a({ ctx, step }: StepCtx) {
if (!ctx.saleA) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken!)
const r = await api.post(`/api/sales/retail/${ctx.saleA}/post`)
check(step, { kind: 'api', description: 'Re-post Sale A → 200/204',
ok: r.status === 200 || r.status === 204, detail: `status=${r.status}` })
assertInvariant(step, ctx.productId!, ctx.storeId!, 17)
}
export async function step08_movement_count_correct({ ctx, step, report }: StepCtx) {
if (!ctx.productId) { step.status = 'skip'; return }
const cnt = dbMovementCount(ctx.productId, ctx.storeId!)
// 2 supply + 2 sale + 1 reverse(unpost-sale-A) + 1 re-post sale-A = 6 строк.
// Реализация может писать дополнительные movement'ы (например, при unpost
// sale-A пишется reversal с положительным qty; при re-post — обычный negative).
// Минимум 4 (по одному на каждый posted документ) и не больше 8.
check(step, { kind: 'db', description: 'StockMovement содержит как минимум 4 строки',
ok: cnt >= 4, detail: `count=${cnt}` })
check(step, { kind: 'db', description: 'StockMovement не более 8 строк (нет лишних дублей)',
ok: cnt <= 8, detail: `count=${cnt}` })
if (cnt > 8) report.gap(`StockMovement аномально большой: ${cnt} строк после 4 documents + 1 unpost + 1 re-post.`)
}
export async function step09_concurrent_sales_serialized({ ctx, step, report }: StepCtx) {
if (!ctx.productId) { step.status = 'skip'; return }
// Сейчас остаток 17. Создаём два Draft чека по qty=10 каждый, проводим
// ОДНОВРЕМЕННО — один должен 200, второй 409 (Serializable).
const api = makeClient(ctx.adminToken!)
const mk = async () => {
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: 10, unitPrice: 200, vatPercent: 12 }],
paidCash: 2000, paidCard: 0,
})
return sale.data?.id as string
}
const a = await mk()
const b = await mk()
check(step, { kind: 'api', description: 'Два Draft созданы', ok: !!a && !!b })
const [r1, r2] = await Promise.all([
api.post(`/api/sales/retail/${a}/post`),
api.post(`/api/sales/retail/${b}/post`),
])
const statuses = [r1.status, r2.status].sort()
// Ожидаем: один 200/204, второй 409 (или 500 если Serializable retry не настроен).
const success = statuses.filter(s => s >= 200 && s < 300).length
const fail = statuses.filter(s => s >= 400).length
check(step, { kind: 'api', description: 'Ровно один post 2xx, второй 4xx (5xx)',
ok: success === 1 && fail === 1, detail: `statuses=${statuses.join(',')}` })
// Stock не должен уйти в минус ни в каком случае.
const stock = dbQty(ctx.productId, ctx.storeId!)
check(step, { kind: 'db', description: 'Stock >= 0 (не минус из-за гонки)',
ok: stock >= 0, detail: `stock=${stock}` })
if (stock < 0) report.bug({ step: '09', severity: 'critical',
title: 'Конкурентный RetailSale.Post привёл к отрицательному Stock',
detail: `stock=${stock}. Serializable-транзакция не сработала или dropped.` })
if (success === 2) report.bug({ step: '09', severity: 'critical',
title: 'Оба POST /post прошли при остатке < ΣQty',
detail: 'Гонка не отбита — потеря товара.' })
// Инвариант после гонки тоже сохраняется.
const sum = dbMovementSum(ctx.productId, ctx.storeId!)
check(step, { kind: 'db', description: 'Stock == Σ Movement после гонки',
ok: stock === sum, detail: `stock=${stock} sum=${sum}` })
}
export async function step10_final_invariant({ ctx, step }: StepCtx) {
if (!ctx.productId) { step.status = 'skip'; return }
const stock = dbQty(ctx.productId, ctx.storeId!)
const sum = dbMovementSum(ctx.productId, ctx.storeId!)
check(step, { kind: 'db', description: 'Финальный invariant Stock == Σ Movement',
ok: stock === sum, detail: `stock=${stock} sum=${sum}` })
}