diff --git a/tests/e2e/scenarios/stock-concurrency.steps.ts b/tests/e2e/scenarios/stock-concurrency.steps.ts new file mode 100644 index 0000000..64eeb00 --- /dev/null +++ b/tests/e2e/scenarios/stock-concurrency.steps.ts @@ -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 { + 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 { + 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 { + 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}` }) +} diff --git a/tests/e2e/scenarios/stock-concurrency.yml b/tests/e2e/scenarios/stock-concurrency.yml new file mode 100644 index 0000000..3318fb3 --- /dev/null +++ b/tests/e2e/scenarios/stock-concurrency.yml @@ -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"