test(e2e): scenario stock-concurrency — конкурентное проведение приёмок
4 шага: стартовая приёмка, две разные приёмки одного товара одновременно, двойное проведение одной приёмки, финальный инвариант. Главный assert — Stock.Quantity == Σ StockMovement.Quantity под гонкой + корректность скользящего среднего Cost. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
15f27fd16e
commit
ad25e12ce4
238
tests/e2e/scenarios/stock-concurrency.steps.ts
Normal file
238
tests/e2e/scenarios/stock-concurrency.steps.ts
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
/**
|
||||||
|
* 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}` })
|
||||||
|
}
|
||||||
24
tests/e2e/scenarios/stock-concurrency.yml
Normal file
24
tests/e2e/scenarios/stock-concurrency.yml
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
name: stock-concurrency
|
||||||
|
description: |
|
||||||
|
Конкурентные операции со складом на стороне ПРИЁМОК (Supply.Post).
|
||||||
|
RetailSale.Post уже под Serializable (см. stock-invariant-deep step09),
|
||||||
|
а Supply.Post исторически шёл на дефолтной изоляции — отсюда потеря
|
||||||
|
обновлений Stock и гонка в расчёте скользящего среднего Cost при
|
||||||
|
одновременном проведении. Проверяем главный инвариант
|
||||||
|
Stock.Quantity == Σ StockMovement.Quantity под нагрузкой:
|
||||||
|
- две РАЗНЫЕ приёмки одного товара проводятся одновременно,
|
||||||
|
- одна и та же приёмка проводится дважды одновременно (double-post).
|
||||||
|
|
||||||
|
preconditions:
|
||||||
|
reset_db: true
|
||||||
|
smoke_login_super_admin: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: step01_bootstrap
|
||||||
|
title: "Орг + товар + стартовая приёмка qty=5 @100 (Stock=5, Cost=100)"
|
||||||
|
- id: step02_concurrent_distinct_supplies
|
||||||
|
title: "Две разные приёмки (10@100 и 10@120) одновременно → Stock=25, инвариант, Cost=108"
|
||||||
|
- id: step03_double_post_same_supply
|
||||||
|
title: "Двойное проведение ОДНОЙ приёмки (7@100) одновременно → применяется один раз"
|
||||||
|
- id: step04_final_invariant
|
||||||
|
title: "Финальный инвариант Stock == Σ StockMovement"
|
||||||
Loading…
Reference in a new issue