test(e2e): scenario documents-edge — критичные edge-кейсы посту
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>
This commit is contained in:
parent
7a4b34bc2f
commit
17a454cce5
105
tests/e2e/reports/documents-edge-2026-05-23T07-32-53-747Z.md
Normal file
105
tests/e2e/reports/documents-edge-2026-05-23T07-32-53-747Z.md
Normal file
|
|
@ -0,0 +1,105 @@
|
||||||
|
# E2E report: documents-edge
|
||||||
|
|
||||||
|
Запущен: 2026-05-23T07:32:43.038Z
|
||||||
|
Длительность: 7.8с
|
||||||
|
|
||||||
|
**Итог:** 10 ✓ / 0 ✗ / 0 ⚠ / 0 ◯ (всего 10)
|
||||||
|
|
||||||
|
## ✓ Step step01_bootstrap: SuperAdmin создаёт орг Test + admin, делаем product + supply (10 шт по 100 KZT)
|
||||||
|
|
||||||
|
Длительность: 4463мс
|
||||||
|
|
||||||
|
| Тип | Проверка | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| api | Орг + админ созданы | ✓ org=f08ddf4a-8b1f-47e6-8c77-9a819330266c |
|
||||||
|
| api | Counterparty создан | ✓ |
|
||||||
|
| api | Product создан | ✓ fbcffe23-a038-4c55-b68e-255a8fb06ebf |
|
||||||
|
| api | Supply Draft создана | ✓ afe0920c-f338-4009-9e65-9df63c55f995 |
|
||||||
|
|
||||||
|
## ✓ Step step02_post_supply_stock_10: Supply провести: stock=10, ReferencePrice=100, Cost=100
|
||||||
|
|
||||||
|
Длительность: 634мс
|
||||||
|
|
||||||
|
| Тип | Проверка | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| api | Supply.Post → 200/204 | ✓ actual=204 |
|
||||||
|
| db | Stock.Quantity == 10 | ✓ qty=10 |
|
||||||
|
|
||||||
|
## ✓ Step step03_oversell_blocked: RetailSale qty=15 (больше остатка 10), POST /post возвращает 409
|
||||||
|
|
||||||
|
Длительность: 812мс
|
||||||
|
|
||||||
|
| Тип | Проверка | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| api | POST RetailSale Draft (qty=15) | ✓ actual=201 {"id":"2d69366b-c3b3-49fb-a230-fc5e6bc49ce5","number":"ПР-2026-000001","date":"2026-05-23T07:32:51Z","status":0,"storeId |
|
||||||
|
| api | POST /post → 409 (oversell) | ✓ actual=409 {"error":"Недостаточно остатка для проведения чека.","lines":[{"productId":"fbcffe23-a038-4c55-b68e-255a8fb06ebf","produ |
|
||||||
|
|
||||||
|
## ✓ Step step04_oversell_stock_unchanged: После заблокированного post stock остался 10, StockMovement не добавлен
|
||||||
|
|
||||||
|
Длительность: 328мс
|
||||||
|
|
||||||
|
| Тип | Проверка | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| db | Stock остался 10 после заблокированного post | ✓ qty=10 |
|
||||||
|
| db | Stock == Σ StockMovement (invariant) | ✓ sum=10 qty=10 |
|
||||||
|
|
||||||
|
## ✓ Step step05_payment_mismatch_blocked: RetailSale с PaidCash+PaidCard не равной Total отвергается на post
|
||||||
|
|
||||||
|
Длительность: 79мс
|
||||||
|
|
||||||
|
| Тип | Проверка | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| api | Платёж ≠ Total → 4xx на post | ✓ actual=400 {"error":"Сумма оплаты 300.00 меньше итога 400.00. Доплатите или измените позиции чека.","field":"PaidCash"} |
|
||||||
|
|
||||||
|
## ✓ Step step06_edit_posted_supply_blocked: PUT проведённой Supply (Posted) возвращает 409
|
||||||
|
|
||||||
|
Длительность: 114мс
|
||||||
|
|
||||||
|
| Тип | Проверка | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| api | PUT проведённой Supply → 409 | ✓ actual=409 {"error":"Только черновик может быть изменён. Сначала отмени проведение."} |
|
||||||
|
|
||||||
|
## ✓ Step step07_delete_posted_supply_blocked: DELETE проведённой Supply возвращает 409
|
||||||
|
|
||||||
|
Длительность: 42мс
|
||||||
|
|
||||||
|
| Тип | Проверка | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| api | DELETE проведённой Supply → 409 | ✓ actual=409 {"error":"Нельзя удалить проведённый документ. Сначала отмени проведение."} |
|
||||||
|
|
||||||
|
## ✓ Step step08_unpost_negative_blocked: После Sale qty=5 unpost Supply qty=10 возвращает 409 (stock минус)
|
||||||
|
|
||||||
|
Длительность: 195мс
|
||||||
|
|
||||||
|
| Тип | Проверка | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| api | Sale qty=5 проведён | ✓ actual=204 |
|
||||||
|
| api | Unpost Supply при stock<unpost-qty → 409 | ✓ actual=409 {"error":"Нельзя отменить проведение: остаток уйдёт в минус (часть товара уже расходована).","lines":[{"productId":"fbcf |
|
||||||
|
|
||||||
|
## ✓ Step step09_barcode_unique_within_org: Дубль штрихкода в одной орге, POST второго product отвергается
|
||||||
|
|
||||||
|
Длительность: 22мс
|
||||||
|
|
||||||
|
| Тип | Проверка | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| api | POST product с тем же barcode → 4xx | ✓ actual=400 {"error":"Штрихкод 201568651undefined5 уже используется товаром «Edge Product 1779521563038»."} |
|
||||||
|
|
||||||
|
## ✓ Step step10_barcode_per_tenant: Тот же штрихкод в другой орге допустим (per-tenant unique)
|
||||||
|
|
||||||
|
Длительность: 1152мс
|
||||||
|
|
||||||
|
| Тип | Проверка | Результат |
|
||||||
|
|---|---|---|
|
||||||
|
| api | POST product с тем же barcode в другой орге → 201 | ✓ actual=201 {"id":"7d885a0a-7ca6-4154-9d46-58a4d6550a83","name":"Tenant-2 product (same barcode)","article":"1","description":null," |
|
||||||
|
| db | В product_barcodes 2 записи с этим Code (одна на орг) | ✓ count=2 |
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
- Passed: 10
|
||||||
|
- Failed: 0
|
||||||
|
- Warnings: 0
|
||||||
|
- Skipped: 0
|
||||||
|
|
||||||
|
## Critical bugs
|
||||||
|
|
||||||
|
Нет.
|
||||||
351
tests/e2e/scenarios/documents-edge.steps.ts
Normal file
351
tests/e2e/scenarios/documents-edge.steps.ts
Normal file
|
|
@ -0,0 +1,351 @@
|
||||||
|
/**
|
||||||
|
* 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}` })
|
||||||
|
}
|
||||||
32
tests/e2e/scenarios/documents-edge.yml
Normal file
32
tests/e2e/scenarios/documents-edge.yml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
name: documents-edge
|
||||||
|
description: |
|
||||||
|
Краевые случаи документов: защита от overselling, запреты на изменение
|
||||||
|
проведённых документов, валидация платежей, уникальность штрихкодов
|
||||||
|
per-tenant. Это сценарий регрессии для самой опасной зоны — потеря
|
||||||
|
денег/остатков при ошибках в RetailSale/Supply посту.
|
||||||
|
|
||||||
|
preconditions:
|
||||||
|
reset_db: true
|
||||||
|
smoke_login_super_admin: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: step01_bootstrap
|
||||||
|
title: "SuperAdmin создаёт орг Test + admin, делаем product + supply (10 шт по 100 KZT)"
|
||||||
|
- id: step02_post_supply_stock_10
|
||||||
|
title: "Supply провести: stock=10, ReferencePrice=100, Cost=100"
|
||||||
|
- id: step03_oversell_blocked
|
||||||
|
title: "RetailSale qty=15 (больше остатка 10), POST /post возвращает 409"
|
||||||
|
- id: step04_oversell_stock_unchanged
|
||||||
|
title: "После заблокированного post stock остался 10, StockMovement не добавлен"
|
||||||
|
- id: step05_payment_mismatch_blocked
|
||||||
|
title: "RetailSale с PaidCash+PaidCard не равной Total отвергается на post"
|
||||||
|
- id: step06_edit_posted_supply_blocked
|
||||||
|
title: "PUT проведённой Supply (Posted) возвращает 409"
|
||||||
|
- id: step07_delete_posted_supply_blocked
|
||||||
|
title: "DELETE проведённой Supply возвращает 409"
|
||||||
|
- id: step08_unpost_negative_blocked
|
||||||
|
title: "После Sale qty=5 unpost Supply qty=10 возвращает 409 (stock минус)"
|
||||||
|
- id: step09_barcode_unique_within_org
|
||||||
|
title: "Дубль штрихкода в одной орге, POST второго product отвергается"
|
||||||
|
- id: step10_barcode_per_tenant
|
||||||
|
title: "Тот же штрихкод в другой орге допустим (per-tenant unique)"
|
||||||
Loading…
Reference in a new issue