food-market/tests/e2e/scenarios/stock-concurrency.steps.ts
nns ad25e12ce4 test(e2e): scenario stock-concurrency — конкурентное проведение приёмок
4 шага: стартовая приёмка, две разные приёмки одного товара одновременно,
двойное проведение одной приёмки, финальный инвариант. Главный assert —
Stock.Quantity == Σ StockMovement.Quantity под гонкой + корректность
скользящего среднего Cost.

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

239 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-concurrency.
*
* Фокус — Supply.Post под конкуренцией. ApplyMovementAsync делает
* read-modify-write по Stock.Quantity (stock.Quantity += delta) без
* RowVersion. Если проведение приёмок не сериализовано, два одновременных
* Post одного товара дают lost update: Stock отстаёт от Σ StockMovement,
* а скользящее среднее Cost считается от устаревшего currentQty.
*
* Главный assert — инвариант Stock.Quantity == Σ StockMovement.Quantity.
*/
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'
import type { AxiosInstance } from 'axios'
const TS = Date.now()
interface Ctx {
apiOnly: boolean
superAdminToken?: string
adminToken?: string
productId?: string
storeId?: string
supplierId?: string
currencyId?: string
retailPriceTypeId?: 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) }
}
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
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 dbCost(productId: string): number {
const out = psql(`SELECT "Cost"::text FROM products WHERE "Id"='${productId}'`).trim()
return Number((out.split('\n')[0] || '0')) || 0
}
function assertInvariant(step: Step, productId: string, storeId: string) {
const stock = dbQty(productId, storeId)
const sum = dbMovementSum(productId, storeId)
check(step, { kind: 'db', description: 'Stock.Quantity == Σ StockMovement (invariant)',
ok: stock === sum, detail: `stock=${stock} sum=${sum}` })
return { stock, sum }
}
// Создаёт черновик приёмки, возвращает его id (без проведения).
async function createSupply(api: AxiosInstance, ctx: Ctx, quantity: number, unitPrice: number): Promise<string | undefined> {
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 }],
})
return sup.status === 201 ? (sup.data.id as string) : undefined
}
// Повторяет /post, пока он не пройдёт (2xx) либо не вернёт явный бизнес-409
// «уже проведён». Конфликт сериализации (повтори запрос) ретраим.
async function postWithRetry(api: AxiosInstance, supplyId: string, attempts = 4): Promise<number> {
let status = 0
for (let i = 0; i < attempts; i++) {
const r = await api.post(`/api/purchases/supplies/${supplyId}/post`)
status = r.status
if (status >= 200 && status < 300) return status
// «Документ уже проведён» — финальное состояние, не ретраим.
if (status === 409 && /провед/i.test(asString(r.data))) return status
await sleep(80 * (i + 1))
}
return status
}
// ---------------------------------------------------------------------------
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 Conc ${TS}`, countryCode: 'KZ',
bin: null, address: null, phone: null, email: null,
defaultCurrencyId: null, accountOwnerUserId: null,
},
adminLastName: 'Conc', adminFirstName: 'Admin',
adminEmail: `stock-conc-${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 cp = await api.post('/api/catalog/counterparties', {
name: `Conc Supplier ${TS}`, type: 1, bin: '987654321012', phone: '+77001112233',
})
ctx.supplierId = cp.data?.id
const prod = await api.post('/api/catalog/products', {
name: `Conc 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) })
if (!ctx.productId) return
// Стартовая приёмка — создаёт строку Stock (чтобы дальше проверять именно
// lost UPDATE, а не конфликт двух INSERT по уникальному индексу).
const s0 = await createSupply(api, ctx, 5, 100)
if (s0) await postWithRetry(api, s0)
const { stock } = assertInvariant(step, ctx.productId, ctx.storeId!)
check(step, { kind: 'db', description: 'Стартовый Stock == 5', ok: stock === 5, detail: `stock=${stock}` })
check(step, { kind: 'db', description: 'Стартовый Cost == 100', ok: dbCost(ctx.productId) === 100, detail: `cost=${dbCost(ctx.productId)}` })
}
export async function step02_concurrent_distinct_supplies({ ctx, step, report }: StepCtx) {
if (!ctx.productId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken!)
const s1 = await createSupply(api, ctx, 10, 100)
const s2 = await createSupply(api, ctx, 10, 120)
check(step, { kind: 'api', description: 'Две приёмки-черновика созданы', ok: !!s1 && !!s2 })
if (!s1 || !s2) return
// Проводим ОДНОВРЕМЕННО. На дефолтной изоляции это даёт lost update:
// оба читают currentQty=5, оба пишут Stock=15 → теряется одна приёмка,
// хотя оба StockMovement записаны. Сериализованный Post переживёт гонку
// (один может получить конфликт сериализации → ретраим вручную).
const [r1, r2] = await Promise.all([
api.post(`/api/purchases/supplies/${s1}/post`),
api.post(`/api/purchases/supplies/${s2}/post`),
])
// Любую неуспешную проводку добиваем ретраями (имитируем поведение клиента).
if (!(r1.status >= 200 && r1.status < 300)) await postWithRetry(api, s1)
if (!(r2.status >= 200 && r2.status < 300)) await postWithRetry(api, s2)
const { stock, sum } = assertInvariant(step, ctx.productId, ctx.storeId!)
check(step, { kind: 'db', description: 'Stock == 25 (5 + 10 + 10, без потери приёмки)',
ok: stock === 25, detail: `stock=${stock} sum=${sum} statuses=${[r1.status, r2.status].sort().join(',')}` })
if (stock !== sum) report.bug({ step: '02', severity: 'critical',
title: 'Конкурентный Supply.Post нарушил инвариант Stock == Σ StockMovement',
detail: `stock=${stock} sum=${sum}. Lost update в ApplyMovementAsync — проведение приёмок не сериализовано.` })
else if (stock !== 25) report.bug({ step: '02', severity: 'critical',
title: 'Потеряна приёмка при конкурентном проведении',
detail: `Stock=${stock}, ожидалось 25. Одна из двух приёмок не применилась к остатку.` })
// Скользящее среднее: (5*100 + 10*100 + 10*120)/25 = 108, порядок не важен.
const cost = dbCost(ctx.productId)
check(step, { kind: 'db', description: 'Cost == 108 (взвешенное среднее по всем трём приёмкам)',
ok: Math.abs(cost - 108) < 0.01, detail: `cost=${cost}` })
if (Math.abs(cost - 108) >= 0.01 && stock === 25) report.bug({ step: '02', severity: 'high',
title: 'Гонка в расчёте скользящего среднего Cost',
detail: `cost=${cost}, ожидалось 108. currentQty прочитан до конкурирующей приёмки.` })
}
export async function step03_double_post_same_supply({ ctx, step, report }: StepCtx) {
if (!ctx.productId) { step.status = 'skip'; return }
const api = makeClient(ctx.adminToken!)
const s3 = await createSupply(api, ctx, 7, 100)
check(step, { kind: 'api', description: 'Приёмка-черновик 7@100 создана', ok: !!s3 })
if (!s3) return
const before = dbQty(ctx.productId, ctx.storeId!)
const cntBefore = dbMovementCount(ctx.productId, ctx.storeId!)
// Двойное проведение ОДНОЙ приёмки одновременно. Должно примениться РОВНО
// один раз: один запрос 2xx, второй — 409/4xx (или конфликт сериализации).
const [r1, r2] = await Promise.all([
api.post(`/api/purchases/supplies/${s3}/post`),
api.post(`/api/purchases/supplies/${s3}/post`),
])
const statuses = [r1.status, r2.status].sort()
const success = statuses.filter((s) => s >= 200 && s < 300).length
check(step, { kind: 'api', description: 'Не более одного успешного проведения',
ok: success <= 1, detail: `statuses=${statuses.join(',')}` })
const after = dbQty(ctx.productId, ctx.storeId!)
const cntAfter = dbMovementCount(ctx.productId, ctx.storeId!)
check(step, { kind: 'db', description: 'Stock вырос ровно на 7 (приёмка применена один раз)',
ok: after - before === 7, detail: `before=${before} after=${after}` })
check(step, { kind: 'db', description: 'Добавлено ровно одно StockMovement',
ok: cntAfter - cntBefore === 1, detail: `+${cntAfter - cntBefore} movements` })
if (after - before !== 7) report.bug({ step: '03', severity: 'critical',
title: 'Double-post одной приёмки применил остаток дважды',
detail: `Stock вырос на ${after - before} вместо 7. Нет защиты от повторного проведения под гонкой.` })
assertInvariant(step, ctx.productId, ctx.storeId!)
}
export async function step04_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 == Σ StockMovement',
ok: stock === sum, detail: `stock=${stock} sum=${sum}` })
}