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>
This commit is contained in:
parent
e13ac655e5
commit
9f0f071193
276
tests/e2e/scenarios/auth-edge.steps.ts
Normal file
276
tests/e2e/scenarios/auth-edge.steps.ts
Normal file
|
|
@ -0,0 +1,276 @@
|
||||||
|
/**
|
||||||
|
* Step-handlers для auth-edge.
|
||||||
|
*
|
||||||
|
* Проверяем чувствительный для безопасности слой:
|
||||||
|
* - refresh-token rotation (старый refresh инвалидируется после использования),
|
||||||
|
* - подделка JWT (любая модификация → 401),
|
||||||
|
* - деактивированный user не получает новых токенов,
|
||||||
|
* - архивная организация блокирует логин,
|
||||||
|
* - повторный signup на занятый email отвергается, на orphan — реактивирует.
|
||||||
|
*/
|
||||||
|
import { login, makeClient, ADMIN_BASE } from '../lib/api.js'
|
||||||
|
import { psql } from '../lib/db.js'
|
||||||
|
import type { CheckResult, Step, Report } from '../lib/report.js'
|
||||||
|
import axios from 'axios'
|
||||||
|
import { Agent as HttpsAgent } from 'node:https'
|
||||||
|
|
||||||
|
const TS = Date.now()
|
||||||
|
const httpsAgent = new HttpsAgent({ rejectUnauthorized: false })
|
||||||
|
|
||||||
|
interface TokenPair { access: string; refresh?: string }
|
||||||
|
interface Ctx {
|
||||||
|
apiOnly: boolean
|
||||||
|
superAdminToken?: string
|
||||||
|
orgId?: string
|
||||||
|
adminEmail?: string
|
||||||
|
adminPwd?: string
|
||||||
|
adminTokens?: TokenPair
|
||||||
|
rotated?: TokenPair
|
||||||
|
}
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
async function rawToken(body: Record<string, string>): Promise<{ status: number; data: any }> {
|
||||||
|
const res = await axios.post(`${ADMIN_BASE}/connect/token`,
|
||||||
|
new URLSearchParams(body),
|
||||||
|
{ httpsAgent, headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, validateStatus: () => true })
|
||||||
|
return { status: res.status, data: res.data }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginRaw(email: string, password: string): Promise<{ status: number; data: any }> {
|
||||||
|
return rawToken({
|
||||||
|
grant_type: 'password', username: email, password,
|
||||||
|
client_id: 'food-market-web',
|
||||||
|
scope: 'openid profile email roles api offline_access',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshRaw(refreshToken: string): Promise<{ status: number; data: any }> {
|
||||||
|
return rawToken({
|
||||||
|
grant_type: 'refresh_token', refresh_token: refreshToken,
|
||||||
|
client_id: 'food-market-web',
|
||||||
|
scope: 'openid profile email roles api offline_access',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export async function step01_bootstrap_admin({ ctx, step, report }: StepCtx) {
|
||||||
|
const sa = await ensureSuperAdmin(ctx)
|
||||||
|
const api = makeClient(sa)
|
||||||
|
const orgRes = await api.post('/api/super-admin/organizations', {
|
||||||
|
org: {
|
||||||
|
name: `Auth Edge ${TS}`, countryCode: 'KZ',
|
||||||
|
bin: null, address: null, phone: null, email: null,
|
||||||
|
defaultCurrencyId: null, accountOwnerUserId: null,
|
||||||
|
},
|
||||||
|
adminLastName: 'Edge', adminFirstName: 'Auth',
|
||||||
|
adminEmail: `auth-edge-${TS}@example.kz`, adminPosition: null,
|
||||||
|
})
|
||||||
|
if (orgRes.status !== 200) {
|
||||||
|
report.bug({ step: '01', severity: 'critical',
|
||||||
|
title: 'Не удалось создать орг', detail: asString(orgRes.data) }); return
|
||||||
|
}
|
||||||
|
ctx.orgId = orgRes.data.organization.id
|
||||||
|
ctx.adminEmail = orgRes.data.adminEmail
|
||||||
|
ctx.adminPwd = orgRes.data.adminTempPassword
|
||||||
|
|
||||||
|
const tok = await loginRaw(ctx.adminEmail!, ctx.adminPwd!)
|
||||||
|
check(step, { kind: 'api', description: 'Initial login → 200',
|
||||||
|
ok: tok.status === 200, detail: `status=${tok.status}` })
|
||||||
|
if (tok.status === 200) {
|
||||||
|
ctx.adminTokens = { access: tok.data.access_token, refresh: tok.data.refresh_token }
|
||||||
|
check(step, { kind: 'api', description: 'Получили access + refresh',
|
||||||
|
ok: !!ctx.adminTokens.access && !!ctx.adminTokens.refresh })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step02_refresh_token_works({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.adminTokens?.refresh) { step.status = 'skip'; return }
|
||||||
|
const r = await refreshRaw(ctx.adminTokens.refresh)
|
||||||
|
const ok = r.status === 200
|
||||||
|
check(step, { kind: 'api', description: 'POST /connect/token (refresh) → 200',
|
||||||
|
ok, detail: `status=${r.status}` })
|
||||||
|
if (!ok) {
|
||||||
|
report.bug({ step: '02', severity: 'critical',
|
||||||
|
title: 'Refresh-token flow не работает', detail: asString(r.data) }); return
|
||||||
|
}
|
||||||
|
ctx.rotated = { access: r.data.access_token, refresh: r.data.refresh_token }
|
||||||
|
check(step, { kind: 'api', description: 'Новый access ≠ старый',
|
||||||
|
ok: ctx.rotated.access !== ctx.adminTokens.access })
|
||||||
|
check(step, { kind: 'api', description: 'Новый refresh ≠ старый (rotation)',
|
||||||
|
ok: !!ctx.rotated.refresh && ctx.rotated.refresh !== ctx.adminTokens.refresh })
|
||||||
|
|
||||||
|
// Новый access работает на защищённом endpoint'e.
|
||||||
|
const me = await makeClient(ctx.rotated.access).get('/api/me')
|
||||||
|
check(step, { kind: 'api', description: 'Новый access → /api/me 200',
|
||||||
|
ok: me.status === 200, detail: `status=${me.status}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step03_refresh_token_rotates({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.adminTokens?.refresh || !ctx.rotated) { step.status = 'skip'; return }
|
||||||
|
// Пытаемся повторно использовать СТАРЫЙ refresh после ротации.
|
||||||
|
const r = await refreshRaw(ctx.adminTokens.refresh)
|
||||||
|
const ok = r.status >= 400 && r.status < 500
|
||||||
|
check(step, { kind: 'api', description: 'Повторное использование старого refresh → 4xx',
|
||||||
|
ok, detail: `status=${r.status} error=${r.data?.error}` })
|
||||||
|
if (!ok) report.bug({ step: '03', severity: 'high',
|
||||||
|
title: 'Старый refresh-token остаётся валидным после rotation',
|
||||||
|
detail: `status=${r.status}. Это позволяет одной утечкой получить вечный доступ.` })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step04_invalid_refresh_rejected({ step }: StepCtx) {
|
||||||
|
const r = await refreshRaw('not-a-token-just-random-text-aaaa')
|
||||||
|
const ok = r.status >= 400 && r.status < 500
|
||||||
|
check(step, { kind: 'api', description: 'Случайный refresh → 4xx',
|
||||||
|
ok, detail: `status=${r.status} error=${r.data?.error}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step05_tampered_jwt_rejected({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.rotated?.access) { step.status = 'skip'; return }
|
||||||
|
// Берём валидный access, меняем один символ в payload — подпись инвалидна.
|
||||||
|
const parts = ctx.rotated.access.split('.')
|
||||||
|
if (parts.length !== 3) { step.notes.push('access не JWT-формата'); return }
|
||||||
|
// Точечная порча payload: меняем последний символ перед сигнатурой.
|
||||||
|
const payload = parts[1]
|
||||||
|
const tampered = payload.slice(0, -1) + (payload.slice(-1) === 'a' ? 'b' : 'a')
|
||||||
|
const fake = `${parts[0]}.${tampered}.${parts[2]}`
|
||||||
|
const me = await axios.get(`${ADMIN_BASE}/api/me`, {
|
||||||
|
httpsAgent, headers: { Authorization: `Bearer ${fake}` }, validateStatus: () => true,
|
||||||
|
})
|
||||||
|
const ok = me.status === 401
|
||||||
|
check(step, { kind: 'api', description: 'Изменённый payload (подпись не сходится) → 401',
|
||||||
|
ok, detail: `status=${me.status}` })
|
||||||
|
if (!ok && me.status === 200) report.bug({ step: '05', severity: 'critical',
|
||||||
|
title: 'JWT без валидной подписи принят — катастрофическая дыра',
|
||||||
|
detail: `payload изменён, status=${me.status}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step06_random_jwt_rejected({ step }: StepCtx) {
|
||||||
|
// Полностью левый «JWT» (валидная base64 в трёх сегментах, но левая подпись).
|
||||||
|
const fake = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJoYWNrZXIiLCJyb2xlcyI6WyJTdXBlckFkbWluIl19.invalid-sig'
|
||||||
|
const me = await axios.get(`${ADMIN_BASE}/api/me`, {
|
||||||
|
httpsAgent, headers: { Authorization: `Bearer ${fake}` }, validateStatus: () => true,
|
||||||
|
})
|
||||||
|
check(step, { kind: 'api', description: 'Случайный HS256 «JWT» → 401',
|
||||||
|
ok: me.status === 401, detail: `status=${me.status}` })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step07_deactivated_user_blocked({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.adminEmail || !ctx.adminPwd || !ctx.rotated?.refresh) { step.status = 'skip'; return }
|
||||||
|
|
||||||
|
// Деактивируем юзера прямо в БД (через UserManager поднять/собрать сложно).
|
||||||
|
psql(`UPDATE users SET "IsActive" = false WHERE LOWER("Email") = LOWER('${ctx.adminEmail}');`)
|
||||||
|
|
||||||
|
// Попытка login с верным паролем.
|
||||||
|
const tok = await loginRaw(ctx.adminEmail, ctx.adminPwd)
|
||||||
|
check(step, { kind: 'api', description: 'Login деактивированного → 4xx',
|
||||||
|
ok: tok.status >= 400 && tok.status < 500, detail: `status=${tok.status}` })
|
||||||
|
|
||||||
|
// Refresh старого refresh (rotated) — должен быть инвалид.
|
||||||
|
const r = await refreshRaw(ctx.rotated.refresh)
|
||||||
|
const ok = r.status >= 400 && r.status < 500
|
||||||
|
check(step, { kind: 'api', description: 'Refresh деактивированного → 4xx',
|
||||||
|
ok, detail: `status=${r.status} error=${r.data?.error}` })
|
||||||
|
if (!ok) report.bug({ step: '07', severity: 'critical',
|
||||||
|
title: 'Refresh-token деактивированного юзера остаётся валидным',
|
||||||
|
detail: `IsActive=false, но refresh вернул ${r.status}.` })
|
||||||
|
|
||||||
|
// Возвращаем User обратно — для последующих шагов.
|
||||||
|
psql(`UPDATE users SET "IsActive" = true WHERE LOWER("Email") = LOWER('${ctx.adminEmail}');`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step08_archived_org_blocks_login({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.orgId || !ctx.adminEmail || !ctx.adminPwd) { step.status = 'skip'; return }
|
||||||
|
const sa = await ensureSuperAdmin(ctx)
|
||||||
|
const api = makeClient(sa)
|
||||||
|
|
||||||
|
// Чтобы archive прошёл, нужен confirmationName == текущее имя орги.
|
||||||
|
const orgRow = await api.get(`/api/super-admin/organizations/${ctx.orgId}`)
|
||||||
|
const orgName = orgRow.data?.name ?? ''
|
||||||
|
const arch = await api.post(`/api/super-admin/organizations/${ctx.orgId}/archive`,
|
||||||
|
{ confirmationName: orgName, reason: 'E2E auth-edge archive test (>=10 chars)' })
|
||||||
|
check(step, { kind: 'api', description: 'Архивация орг → 200/204',
|
||||||
|
ok: arch.status === 200 || arch.status === 204, detail: `status=${arch.status} ${asString(arch.data).slice(0, 100)}` })
|
||||||
|
if (arch.status >= 400) return
|
||||||
|
|
||||||
|
const tok = await loginRaw(ctx.adminEmail, ctx.adminPwd)
|
||||||
|
const ok = tok.status >= 400 && tok.status < 500
|
||||||
|
check(step, { kind: 'api', description: 'Login юзера архивной орги → 4xx',
|
||||||
|
ok, detail: `status=${tok.status} error=${tok.data?.error_description ?? tok.data?.error}` })
|
||||||
|
if (!ok) report.bug({ step: '08', severity: 'critical',
|
||||||
|
title: 'Login юзера архивной орги разрешён',
|
||||||
|
detail: `status=${tok.status}. Архивные орги должны блокировать вход полностью.` })
|
||||||
|
|
||||||
|
// Восстанавливаем.
|
||||||
|
await api.post(`/api/super-admin/organizations/${ctx.orgId}/restore`,
|
||||||
|
{ reason: 'E2E auth-edge restore back (>=10 chars)' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step09_duplicate_signup_blocked({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.adminEmail) { step.status = 'skip'; return }
|
||||||
|
// Прямой POST /api/auth/signup с тем же email (живая орга после restore).
|
||||||
|
const r = await axios.post(`${ADMIN_BASE}/api/auth/signup`, {
|
||||||
|
email: ctx.adminEmail, password: 'NewPass12345!', organizationName: 'Dup Shop',
|
||||||
|
phone: '+77001234567', plan: 'start',
|
||||||
|
}, { httpsAgent, validateStatus: () => true })
|
||||||
|
const ok = r.status >= 400 && r.status < 500
|
||||||
|
check(step, { kind: 'api', description: 'Повторный signup на занятый email → 4xx',
|
||||||
|
ok, detail: `status=${r.status} ${asString(r.data).slice(0, 120)}` })
|
||||||
|
if (!ok) report.bug({ step: '09', severity: 'high',
|
||||||
|
title: 'Повторный signup на занятый email прошёл — конфликт идентификации',
|
||||||
|
detail: asString(r.data) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step10_orphan_signup_reactivates({ ctx, step, report }: StepCtx) {
|
||||||
|
// Воспроизводим orphan: создаём AppUser без живой organization и тестим signup.
|
||||||
|
// Способ: создаём новую орг, signup'имся, потом DELETE org → user без org. Затем signup тем же email.
|
||||||
|
const orphanEmail = `orphan-${TS}@example.kz`
|
||||||
|
const signup1 = await axios.post(`${ADMIN_BASE}/api/auth/signup`, {
|
||||||
|
email: orphanEmail, password: 'OrphanPwd12!', organizationName: 'Orphan Shop',
|
||||||
|
phone: '+77007779988', plan: 'start',
|
||||||
|
}, { httpsAgent, validateStatus: () => true })
|
||||||
|
check(step, { kind: 'api', description: 'First signup → 200/201',
|
||||||
|
ok: signup1.status === 200 || signup1.status === 201, detail: `status=${signup1.status} ${asString(signup1.data).slice(0, 120)}` })
|
||||||
|
if (signup1.status >= 400) return
|
||||||
|
|
||||||
|
// Удаляем org этого юзера и оставляем user как orphan (через прямой SQL).
|
||||||
|
psql(`
|
||||||
|
UPDATE users SET "OrganizationId" = NULL WHERE LOWER("Email") = LOWER('${orphanEmail}');
|
||||||
|
DELETE FROM organizations WHERE LOWER("Email") = LOWER('${orphanEmail}');
|
||||||
|
`)
|
||||||
|
|
||||||
|
// Второй signup тем же email — по комменту в AuthSignupController это реактивирует.
|
||||||
|
const signup2 = await axios.post(`${ADMIN_BASE}/api/auth/signup`, {
|
||||||
|
email: orphanEmail, password: 'ReactivatedPwd1!', organizationName: 'Reactivated Shop',
|
||||||
|
phone: '+77007779988', plan: 'start',
|
||||||
|
}, { httpsAgent, validateStatus: () => true })
|
||||||
|
const reactivated = signup2.status === 200 || signup2.status === 201
|
||||||
|
check(step, { kind: 'api', description: 'Re-signup orphan email → 200/201 (реактивация)',
|
||||||
|
ok: reactivated, detail: `status=${signup2.status} ${asString(signup2.data).slice(0, 150)}` })
|
||||||
|
|
||||||
|
// Логин с новым паролем должен работать.
|
||||||
|
if (reactivated) {
|
||||||
|
const tok = await loginRaw(orphanEmail, 'ReactivatedPwd1!')
|
||||||
|
check(step, { kind: 'api', description: 'Login после реактивации → 200',
|
||||||
|
ok: tok.status === 200, detail: `status=${tok.status}` })
|
||||||
|
} else {
|
||||||
|
report.bug({ step: '10', severity: 'medium',
|
||||||
|
title: 'Orphan-юзер не может реактивироваться через signup',
|
||||||
|
detail: `Спецификация в AuthSignupController описывает реактивацию. Got ${signup2.status}.` })
|
||||||
|
}
|
||||||
|
}
|
||||||
31
tests/e2e/scenarios/auth-edge.yml
Normal file
31
tests/e2e/scenarios/auth-edge.yml
Normal file
|
|
@ -0,0 +1,31 @@
|
||||||
|
name: auth-edge
|
||||||
|
description: |
|
||||||
|
Краевые случаи аутентификации и сессий. Регресс на refresh-token flow
|
||||||
|
(получение, ротация, отзыв), подделка JWT, повторный signup, вход
|
||||||
|
деактивированного юзера, вход юзера архивной орги.
|
||||||
|
|
||||||
|
preconditions:
|
||||||
|
reset_db: true
|
||||||
|
smoke_login_super_admin: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: step01_bootstrap_admin
|
||||||
|
title: "SuperAdmin создаёт орг + админа, получаем access+refresh"
|
||||||
|
- id: step02_refresh_token_works
|
||||||
|
title: "Refresh: старый access обменивается на новые access+refresh"
|
||||||
|
- id: step03_refresh_token_rotates
|
||||||
|
title: "После refresh — старый refresh-token больше не работает (rotation)"
|
||||||
|
- id: step04_invalid_refresh_rejected
|
||||||
|
title: "Невалидный refresh-token возвращает 400 invalid_grant"
|
||||||
|
- id: step05_tampered_jwt_rejected
|
||||||
|
title: "JWT с подделанным org_id (изменён без переподписи) отшивается 401"
|
||||||
|
- id: step06_random_jwt_rejected
|
||||||
|
title: "Случайный JWT-подобный токен из другого ключа отшивается 401"
|
||||||
|
- id: step07_deactivated_user_blocked
|
||||||
|
title: "Деактивация User.IsActive=false: повторный login и refresh возвращают 400"
|
||||||
|
- id: step08_archived_org_blocks_login
|
||||||
|
title: "Архивная организация: login существующего админа возвращает 400 invalid_grant"
|
||||||
|
- id: step09_duplicate_signup_blocked
|
||||||
|
title: "Повторный signup с тем же email живой орги отвергается 400"
|
||||||
|
- id: step10_orphan_signup_reactivates
|
||||||
|
title: "Signup с email orphan-юзера (его org удалена) — реактивирует с новой org"
|
||||||
287
tests/e2e/scenarios/catalog-edge.steps.ts
Normal file
287
tests/e2e/scenarios/catalog-edge.steps.ts
Normal file
|
|
@ -0,0 +1,287 @@
|
||||||
|
/**
|
||||||
|
* Step-handlers для catalog-edge.
|
||||||
|
*
|
||||||
|
* Краевые проверки CRUD справочников:
|
||||||
|
* - валидация пустых/отрицательных/слишком длинных полей,
|
||||||
|
* - циклические ссылки в иерархии,
|
||||||
|
* - FK-защита (нельзя удалить используемое),
|
||||||
|
* - дубликаты с уникальными ограничениями.
|
||||||
|
*/
|
||||||
|
import { login, makeClient } from '../lib/api.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
|
||||||
|
orgId?: string
|
||||||
|
unitId?: string
|
||||||
|
groupId?: string
|
||||||
|
rootGroupId?: string
|
||||||
|
currencyId?: string
|
||||||
|
retailPriceTypeId?: string
|
||||||
|
storeId?: string
|
||||||
|
retailPointId?: string
|
||||||
|
supplierId?: string
|
||||||
|
productId?: 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
|
||||||
|
}
|
||||||
|
|
||||||
|
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: `Catalog Edge ${TS}`, countryCode: 'KZ',
|
||||||
|
bin: null, address: null, phone: null, email: null,
|
||||||
|
defaultCurrencyId: null, accountOwnerUserId: null,
|
||||||
|
},
|
||||||
|
adminLastName: 'Edge', adminFirstName: 'Cat',
|
||||||
|
adminEmail: `cat-edge-${TS}@example.kz`, adminPosition: null,
|
||||||
|
})
|
||||||
|
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
|
||||||
|
const api = makeClient(ctx.adminToken)
|
||||||
|
|
||||||
|
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.rootGroupId = grps.data?.items?.[0]?.id
|
||||||
|
if (!ctx.rootGroupId) {
|
||||||
|
const g = await api.post('/api/catalog/product-groups', { name: 'Root', parentId: null })
|
||||||
|
ctx.rootGroupId = g.data?.id
|
||||||
|
}
|
||||||
|
ctx.groupId = ctx.rootGroupId
|
||||||
|
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
|
||||||
|
check(step, { kind: 'api', description: 'Bootstrap lookups получены',
|
||||||
|
ok: !!ctx.unitId && !!ctx.groupId && !!ctx.currencyId && !!ctx.retailPriceTypeId && !!ctx.storeId })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step02_empty_product_name_rejected({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.adminToken) { step.status = 'skip'; return }
|
||||||
|
const api = makeClient(ctx.adminToken)
|
||||||
|
const r = await api.post('/api/catalog/products', {
|
||||||
|
name: '', unitOfMeasureId: ctx.unitId, productGroupId: ctx.groupId,
|
||||||
|
vat: 12, vatEnabled: true,
|
||||||
|
barcodes: [{ code: generateEan13(), type: 1, isPrimary: true }],
|
||||||
|
prices: [{ priceTypeId: ctx.retailPriceTypeId, currencyId: ctx.currencyId, amount: 100 }],
|
||||||
|
})
|
||||||
|
const ok = r.status === 400
|
||||||
|
check(step, { kind: 'api', description: 'POST product с пустым name → 400',
|
||||||
|
ok, detail: `status=${r.status} ${asString(r.data).slice(0, 120)}` })
|
||||||
|
if (!ok && r.status >= 200 && r.status < 300) report.bug({ step: '02', severity: 'medium',
|
||||||
|
title: 'Создан product с пустым name', detail: asString(r.data) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step03_negative_price_rejected({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.adminToken) { step.status = 'skip'; return }
|
||||||
|
const api = makeClient(ctx.adminToken)
|
||||||
|
const r = await api.post('/api/catalog/products', {
|
||||||
|
name: 'NegPrice', unitOfMeasureId: ctx.unitId, productGroupId: ctx.groupId,
|
||||||
|
vat: 12, vatEnabled: true,
|
||||||
|
barcodes: [{ code: generateEan13(), type: 1, isPrimary: true }],
|
||||||
|
prices: [{ priceTypeId: ctx.retailPriceTypeId, currencyId: ctx.currencyId, amount: -100 }],
|
||||||
|
})
|
||||||
|
const ok = r.status === 400
|
||||||
|
check(step, { kind: 'api', description: 'POST product с amount=-100 → 400',
|
||||||
|
ok, detail: `status=${r.status} ${asString(r.data).slice(0, 120)}` })
|
||||||
|
if (!ok && r.status >= 200 && r.status < 300) report.bug({ step: '03', severity: 'high',
|
||||||
|
title: 'Создан product с отрицательной ценой',
|
||||||
|
detail: 'Range(0, 1e10) на ProductPriceInput.Amount должен это отбить.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step04_oversized_name_truncated_or_rejected({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.adminToken) { step.status = 'skip'; return }
|
||||||
|
const api = makeClient(ctx.adminToken)
|
||||||
|
const longName = 'A'.repeat(600) // products.Name варчар 500
|
||||||
|
const r = await api.post('/api/catalog/products', {
|
||||||
|
name: longName, unitOfMeasureId: ctx.unitId, productGroupId: ctx.groupId,
|
||||||
|
vat: 12, vatEnabled: true,
|
||||||
|
barcodes: [{ code: generateEan13(), type: 1, isPrimary: true }],
|
||||||
|
prices: [{ priceTypeId: ctx.retailPriceTypeId, currencyId: ctx.currencyId, amount: 100 }],
|
||||||
|
})
|
||||||
|
// Идеально — 400 (валидация на бэке). Допустимо 500 если БД отбивает ALTER.
|
||||||
|
const ok = r.status >= 400 && r.status < 500
|
||||||
|
check(step, { kind: 'api', description: 'POST product с name=600 chars → 4xx',
|
||||||
|
ok, detail: `status=${r.status} ${asString(r.data).slice(0, 100)}` })
|
||||||
|
if (r.status >= 500) report.gap('Слишком длинный Product.Name возвращает 5xx — должно быть 400 с понятным сообщением.')
|
||||||
|
if (r.status >= 200 && r.status < 300) report.bug({ step: '04', severity: 'medium',
|
||||||
|
title: 'Принят name > 500 символов', detail: asString(r.data) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step05_duplicate_product_article({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.adminToken) { step.status = 'skip'; return }
|
||||||
|
const api = makeClient(ctx.adminToken)
|
||||||
|
const article = `ART-${TS}`
|
||||||
|
const a = await api.post('/api/catalog/products', {
|
||||||
|
name: 'First', article, unitOfMeasureId: ctx.unitId, productGroupId: ctx.groupId,
|
||||||
|
vat: 12, vatEnabled: true,
|
||||||
|
barcodes: [{ code: generateEan13(), type: 1, isPrimary: true }],
|
||||||
|
prices: [{ priceTypeId: ctx.retailPriceTypeId, currencyId: ctx.currencyId, amount: 100 }],
|
||||||
|
})
|
||||||
|
ctx.productId = a.data?.id
|
||||||
|
check(step, { kind: 'api', description: 'POST 1-й product с article OK',
|
||||||
|
ok: a.status === 201, detail: `status=${a.status}` })
|
||||||
|
|
||||||
|
const b = await api.post('/api/catalog/products', {
|
||||||
|
name: 'Second', article, unitOfMeasureId: ctx.unitId, productGroupId: ctx.groupId,
|
||||||
|
vat: 12, vatEnabled: true,
|
||||||
|
barcodes: [{ code: generateEan13(), type: 1, isPrimary: true }],
|
||||||
|
prices: [{ priceTypeId: ctx.retailPriceTypeId, currencyId: ctx.currencyId, amount: 100 }],
|
||||||
|
})
|
||||||
|
// Если article уникален per-org — должен 4xx. Если нет — 201, и это надо отметить.
|
||||||
|
if (b.status === 201) {
|
||||||
|
report.gap('Article у Product не уникален per-org — два товара могут иметь одинаковый артикул, путаница в учёте.')
|
||||||
|
check(step, { kind: 'api', description: 'POST 2-й product с тем же article (gap)',
|
||||||
|
ok: true, detail: 'не запрещено сервером' })
|
||||||
|
} else {
|
||||||
|
check(step, { kind: 'api', description: 'POST 2-й product с тем же article → 4xx',
|
||||||
|
ok: b.status >= 400 && b.status < 500, detail: `status=${b.status} ${asString(b.data).slice(0, 100)}` })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step06_self_parent_group_rejected({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.adminToken || !ctx.rootGroupId) { step.status = 'skip'; return }
|
||||||
|
const api = makeClient(ctx.adminToken)
|
||||||
|
const r = await api.put(`/api/catalog/product-groups/${ctx.rootGroupId}`, {
|
||||||
|
name: 'SelfParent', parentId: ctx.rootGroupId,
|
||||||
|
})
|
||||||
|
const ok = r.status >= 400 && r.status < 500
|
||||||
|
check(step, { kind: 'api', description: 'PUT product-group parentId=self → 4xx',
|
||||||
|
ok, detail: `status=${r.status} ${asString(r.data).slice(0, 100)}` })
|
||||||
|
if (!ok && r.status >= 200 && r.status < 300) report.bug({ step: '06', severity: 'high',
|
||||||
|
title: 'ProductGroup может ссылаться на саму себя как parent — циклическая иерархия',
|
||||||
|
detail: 'Это сломает рендеринг дерева и Path-кэш.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step07_delete_group_with_children({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.adminToken || !ctx.rootGroupId) { step.status = 'skip'; return }
|
||||||
|
const api = makeClient(ctx.adminToken)
|
||||||
|
// Создадим дочернюю группу.
|
||||||
|
const child = await api.post('/api/catalog/product-groups', { name: `Child ${TS}`, parentId: ctx.rootGroupId })
|
||||||
|
if (child.status !== 201) { step.notes.push(`Не создана child: ${child.status}`); return }
|
||||||
|
const r = await api.delete(`/api/catalog/product-groups/${ctx.rootGroupId}`)
|
||||||
|
const ok = r.status === 409 || r.status === 400
|
||||||
|
check(step, { kind: 'api', description: 'DELETE group с детьми → 4xx',
|
||||||
|
ok, detail: `status=${r.status} ${asString(r.data).slice(0, 100)}` })
|
||||||
|
if (!ok && r.status >= 200 && r.status < 300) report.bug({ step: '07', severity: 'critical',
|
||||||
|
title: 'DELETE group с детьми прошёл — orphan child group',
|
||||||
|
detail: 'FK-restrict в БД и явная проверка в контроллере должны блокировать.' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step08_delete_group_with_products({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.adminToken || !ctx.groupId || !ctx.productId) { step.status = 'skip'; return }
|
||||||
|
const api = makeClient(ctx.adminToken)
|
||||||
|
// ProductId уже в этой группе из step05.
|
||||||
|
const r = await api.delete(`/api/catalog/product-groups/${ctx.groupId}`)
|
||||||
|
const ok = r.status === 409 || r.status === 400
|
||||||
|
check(step, { kind: 'api', description: 'DELETE group с продуктами → 4xx',
|
||||||
|
ok, detail: `status=${r.status} ${asString(r.data).slice(0, 100)}` })
|
||||||
|
if (!ok && r.status >= 200 && r.status < 300) report.bug({ step: '08', severity: 'critical',
|
||||||
|
title: 'DELETE group с привязанными product — orphan products',
|
||||||
|
detail: asString(r.data) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step09_delete_unit_with_products({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.adminToken || !ctx.unitId) { step.status = 'skip'; return }
|
||||||
|
const api = makeClient(ctx.adminToken)
|
||||||
|
const r = await api.delete(`/api/catalog/units-of-measure/${ctx.unitId}/enable`)
|
||||||
|
const ok = r.status === 409 || r.status === 400
|
||||||
|
check(step, { kind: 'api', description: 'DELETE enable у unit с продуктами → 4xx',
|
||||||
|
ok, detail: `status=${r.status} ${asString(r.data).slice(0, 150)}` })
|
||||||
|
if (!ok && r.status >= 200 && r.status < 300) report.bug({ step: '09', severity: 'high',
|
||||||
|
title: 'DELETE enable у unit с продуктами прошёл — broken FK',
|
||||||
|
detail: asString(r.data) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step10_delete_system_price_type({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.adminToken) { step.status = 'skip'; return }
|
||||||
|
const api = makeClient(ctx.adminToken)
|
||||||
|
const list = await api.get('/api/catalog/price-types')
|
||||||
|
const sys = list.data?.items?.find((p: { isSystem: boolean }) => p.isSystem)
|
||||||
|
if (!sys) { step.notes.push('Нет системных PriceType в орге'); return }
|
||||||
|
const r = await api.delete(`/api/catalog/price-types/${sys.id}`)
|
||||||
|
const ok = r.status === 409 || r.status === 400 || r.status === 403
|
||||||
|
check(step, { kind: 'api', description: 'DELETE системной PriceType → 4xx',
|
||||||
|
ok, detail: `status=${r.status} ${asString(r.data).slice(0, 100)}` })
|
||||||
|
if (!ok && r.status >= 200 && r.status < 300) report.bug({ step: '10', severity: 'high',
|
||||||
|
title: 'DELETE PriceType.IsSystem прошёл — системный справочник не защищён',
|
||||||
|
detail: asString(r.data) })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step11_second_retail_price_type({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.adminToken) { step.status = 'skip'; return }
|
||||||
|
const api = makeClient(ctx.adminToken)
|
||||||
|
// Контракт: POST IsRetail=true допустимо, но СЕМАНТИЧЕСКИ сервер переносит
|
||||||
|
// флаг — старый IsRetail сбрасывается в false (см. PriceTypesController.Create
|
||||||
|
// ExecuteUpdateAsync(IsRetail=false)). Проверяем что после операции ровно
|
||||||
|
// один IsRetail остался — иначе либо 4xx, либо это баг.
|
||||||
|
const r = await api.post('/api/catalog/price-types', {
|
||||||
|
name: 'Вторая розничная', isRequired: false, isRetail: true, sortOrder: 99,
|
||||||
|
})
|
||||||
|
check(step, { kind: 'api', description: 'POST второй IsRetail PriceType — 201 (флаг перенесён) или 4xx',
|
||||||
|
ok: r.status === 201 || (r.status >= 400 && r.status < 500), detail: `status=${r.status}` })
|
||||||
|
|
||||||
|
const list = await api.get('/api/catalog/price-types?pageSize=200')
|
||||||
|
const retails = (list.data?.items ?? []).filter((p: { isRetail: boolean }) => p.isRetail)
|
||||||
|
check(step, { kind: 'api', description: 'IsRetail=true ровно у одного PriceType (uniqueness)',
|
||||||
|
ok: retails.length === 1, detail: `count=${retails.length}` })
|
||||||
|
if (retails.length !== 1) report.bug({ step: '11', severity: 'high',
|
||||||
|
title: 'Нарушена уникальность IsRetail (несколько розничных типов цен на орг)',
|
||||||
|
detail: `Найдено ${retails.length} типов с IsRetail=true.` })
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function step12_delete_counterparty_with_supply({ ctx, step, report }: StepCtx) {
|
||||||
|
if (!ctx.adminToken || !ctx.productId) { step.status = 'skip'; return }
|
||||||
|
const api = makeClient(ctx.adminToken)
|
||||||
|
|
||||||
|
// Создаём counterparty + supply на него.
|
||||||
|
const cp = await api.post('/api/catalog/counterparties', {
|
||||||
|
name: `Cat Edge Supplier ${TS}`, type: 1, bin: '111122223333', phone: '+77001112233',
|
||||||
|
})
|
||||||
|
if (cp.status !== 201) { step.notes.push(`counterparty create: ${cp.status}`); return }
|
||||||
|
ctx.supplierId = cp.data.id
|
||||||
|
|
||||||
|
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: 1, unitPrice: 50 }],
|
||||||
|
})
|
||||||
|
if (sup.status !== 201) { step.notes.push(`supply create: ${sup.status}`); return }
|
||||||
|
|
||||||
|
// Удалить counterparty — должен быть 409 (FK).
|
||||||
|
const del = await api.delete(`/api/catalog/counterparties/${ctx.supplierId}`)
|
||||||
|
const ok = del.status === 409 || del.status === 400
|
||||||
|
check(step, { kind: 'api', description: 'DELETE counterparty с Supply → 4xx',
|
||||||
|
ok, detail: `status=${del.status} ${asString(del.data).slice(0, 120)}` })
|
||||||
|
if (!ok && del.status >= 200 && del.status < 300) report.bug({ step: '12', severity: 'critical',
|
||||||
|
title: 'DELETE counterparty с привязанным Supply прошёл — orphan документы',
|
||||||
|
detail: asString(del.data) })
|
||||||
|
}
|
||||||
35
tests/e2e/scenarios/catalog-edge.yml
Normal file
35
tests/e2e/scenarios/catalog-edge.yml
Normal file
|
|
@ -0,0 +1,35 @@
|
||||||
|
name: catalog-edge
|
||||||
|
description: |
|
||||||
|
Краевые случаи CRUD справочников: дубликаты, FK-защита при удалении,
|
||||||
|
валидация пустых/слишком длинных/отрицательных значений, циклы в
|
||||||
|
иерархии групп. Все эти проверки защищают целостность справочников.
|
||||||
|
|
||||||
|
preconditions:
|
||||||
|
reset_db: true
|
||||||
|
smoke_login_super_admin: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: step01_bootstrap
|
||||||
|
title: "Орг + admin + lookups"
|
||||||
|
- id: step02_empty_product_name_rejected
|
||||||
|
title: "POST product с пустым name → 400"
|
||||||
|
- id: step03_negative_price_rejected
|
||||||
|
title: "POST product с отрицательной ценой amount=-100 → 400"
|
||||||
|
- id: step04_oversized_name_truncated_or_rejected
|
||||||
|
title: "POST product с name > 500 символов → 400 (превышение maxLength)"
|
||||||
|
- id: step05_duplicate_product_article
|
||||||
|
title: "POST второго product с тем же article → 4xx (если уникальный) или OK + проверка БД"
|
||||||
|
- id: step06_self_parent_group_rejected
|
||||||
|
title: "POST product-group с parentId=собственный id (цикл) → 400"
|
||||||
|
- id: step07_delete_group_with_children
|
||||||
|
title: "DELETE group у которой есть подгруппы → 409"
|
||||||
|
- id: step08_delete_group_with_products
|
||||||
|
title: "DELETE group в которой есть продукты → 409"
|
||||||
|
- id: step09_delete_unit_with_products
|
||||||
|
title: "DELETE enable у unit, на которую ссылаются продукты → 409"
|
||||||
|
- id: step10_delete_system_price_type
|
||||||
|
title: "DELETE PriceType.IsSystem=true → 409"
|
||||||
|
- id: step11_second_retail_price_type
|
||||||
|
title: "POST PriceType с IsRetail=true когда уже есть Retail → 409"
|
||||||
|
- id: step12_delete_counterparty_with_supply
|
||||||
|
title: "DELETE counterparty который использован в Supply → 409"
|
||||||
272
tests/e2e/scenarios/stock-invariant-deep.steps.ts
Normal file
272
tests/e2e/scenarios/stock-invariant-deep.steps.ts
Normal file
|
|
@ -0,0 +1,272 @@
|
||||||
|
/**
|
||||||
|
* 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}` })
|
||||||
|
}
|
||||||
32
tests/e2e/scenarios/stock-invariant-deep.yml
Normal file
32
tests/e2e/scenarios/stock-invariant-deep.yml
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
name: stock-invariant-deep
|
||||||
|
description: |
|
||||||
|
Углублённая проверка инварианта Stock.Quantity == Σ StockMovement по
|
||||||
|
(product, store). Серия операций (Supply ×2, RetailSale ×2, Unpost,
|
||||||
|
Re-post, ещё Supply) с проверкой после каждой. Плюс конкурентный
|
||||||
|
POST двух чеков на один остаток — Serializable должен отбить один 409.
|
||||||
|
|
||||||
|
preconditions:
|
||||||
|
reset_db: true
|
||||||
|
smoke_login_super_admin: true
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- id: step01_bootstrap
|
||||||
|
title: "Орг + admin + product (стартовый остаток 0)"
|
||||||
|
- id: step02_supply_a_qty_20
|
||||||
|
title: "Supply A qty=20 → invariant stock=20, Σ movement=20"
|
||||||
|
- id: step03_sale_a_qty_5
|
||||||
|
title: "RetailSale A qty=5 → invariant stock=15, Σ movement=15"
|
||||||
|
- id: step04_supply_b_qty_10
|
||||||
|
title: "Supply B qty=10 → invariant stock=25, Σ movement=25"
|
||||||
|
- id: step05_sale_b_qty_8
|
||||||
|
title: "RetailSale B qty=8 → invariant stock=17, Σ movement=17"
|
||||||
|
- id: step06_unpost_sale_a
|
||||||
|
title: "Unpost RetailSale A → invariant stock=22, Σ movement=22"
|
||||||
|
- id: step07_repost_sale_a
|
||||||
|
title: "Re-post RetailSale A → invariant stock=17, Σ movement=17"
|
||||||
|
- id: step08_movement_count_correct
|
||||||
|
title: "Всего StockMovement по продукту = 6 строк (2 supply + 2 sale + reverse sale + repost sale)"
|
||||||
|
- id: step09_concurrent_sales_serialized
|
||||||
|
title: "Два POST /post одновременно на один остаток — один 200, второй 409"
|
||||||
|
- id: step10_final_invariant
|
||||||
|
title: "Финальный invariant после всех операций сохраняется"
|
||||||
Loading…
Reference in a new issue