test(e2e): filters-sweep — смысловая проверка фильтров списков
Независимо считает ожидаемый набор строк/итог (не «200 OK»): свежая temp-орг, контролируемый сид (внук-со-стоком/ребёнок-ноль/родитель-минус/поиск/услуга в 3-уровневой группе), сверка для каждого контрола. Покрыто: stock includeZero, negativeOnly, groupId/productGroupId (3 уровня вложенности), store-specific, поиск по артикулу/штрихкоду, сортировка по количеству, пагинация (разбиение), экспорт-с-фильтром; catalog groupId/productGroupId/isService. 21/21 ✓ на стенде. Добавлено в nightly (step 3c) и quality-watchdog (step 12) на хосте. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
d7db011331
commit
1cc3c0a9dc
|
|
@ -82,14 +82,21 @@ _(pageSize=50, время полного HTTP-ответа)_
|
|||
вложенность покрыта integration-тестом `Stock_filter_by_group_includes_nested_groups`.
|
||||
|
||||
## Часть 2. Реестр фильтров и e2e meaning-checks
|
||||
- [ ] Реестр: каждая web-страница-список × каждый контрол (поиск, фильтры/тумблеры,
|
||||
селекторы склад/группа/период/статус, сортировка по каждой колонке, пагинация,
|
||||
сброс, экспорт-с-фильтрами).
|
||||
- [ ] Для каждого — e2e в реальном браузере, где тест независимо считает ожидаемый
|
||||
набор строк/итог (не «200 OK»). Любое расхождение = баг (репро→фикс→тест→деплой).
|
||||
- [ ] Синонимы параметров groupId/productGroupId на всех list-эндпоинтах.
|
||||
- [ ] filters-sweep в nightly (~/nightly-verify.sh) и quality-watchdog (ежедневно).
|
||||
- [ ] Кратко — то же для mobile API.
|
||||
- [x] `tests/e2e/filters-sweep.ts` + `filters-sweep.sh`: поднимает свежую temp-орг,
|
||||
сеет контролируемый набор (внук-со-стоком / ребёнок-ноль / родитель-минус / поиск /
|
||||
услуга в 3-уровневой группе), и для каждого контрола НЕЗАВИСИМО считает ожидаемый
|
||||
набор id и сверяет. 21/21 ✓ на стенде:
|
||||
- stock: includeZero (нулевые), negativeOnly, groupId/productGroupId (3 уровня),
|
||||
store-specific (пусто/агрегат), поиск по арт/ШК, сортировка по кол-ву (монотонна),
|
||||
пагинация (разбиение без пересечений, total), экспорт-с-фильтром (число строк).
|
||||
- catalog: groupId/productGroupId (поддерево), isService.
|
||||
- [x] Синонимы groupId/productGroupId проверяются и для stock, и для catalog.
|
||||
- [x] filters-sweep в nightly (`~/nightly-verify.sh` step 3c) и quality-watchdog
|
||||
(`~/quality-watchdog.sh` step 12, суточная каденция в 03:XX).
|
||||
- [~] Mobile API: те же /api/inventory/stock проверки покрыты (iOS ходит в этот же
|
||||
эндпоинт) + integration MobileApiTests. Остальные моб-списки — по мере необходимости.
|
||||
- [ ] Расширить реестр на остальные web-списки (продажи/приёмки/движения/контрагенты/
|
||||
сотрудники) — периоды/статусы/сорт — следующими итерациями.
|
||||
|
||||
## Часть 4. Приёмочное тестирование процессов (UAT) — см. docs/uat-report-<date>.md
|
||||
- [ ] Матрица покрытия: что уже зелёное (не перепроверять) vs новое.
|
||||
|
|
|
|||
22
tests/e2e/filters-sweep.sh
Executable file
22
tests/e2e/filters-sweep.sh
Executable file
|
|
@ -0,0 +1,22 @@
|
|||
#!/usr/bin/env bash
|
||||
# Смысловая проверка фильтров всех списков: не «200 OK», а независимо посчитанный
|
||||
# ожидаемый набор строк/итог (нулевые остатки, группы с вложенностью, синонимы
|
||||
# groupId/productGroupId, поиск, сортировка, пагинация, экспорт-с-фильтром).
|
||||
# Поднимает свежую temp-организацию на стенде, сеет контролируемые данные, сверяет.
|
||||
# Выход != 0 если есть расхождение — для nightly/quality-watchdog.
|
||||
#
|
||||
# tests/e2e/filters-sweep.sh
|
||||
# Env: E2E_ADMIN_URL (default https://test.admin.food-market.kz)
|
||||
set -uo pipefail
|
||||
cd "$(dirname "$0")"
|
||||
export E2E_ADMIN_URL="${E2E_ADMIN_URL:-https://test.admin.food-market.kz}"
|
||||
[ -d node_modules ] || pnpm install --silent
|
||||
echo "[filters-sweep] стенд=$E2E_ADMIN_URL"
|
||||
pnpm exec tsx filters-sweep.ts
|
||||
RC=$?
|
||||
if [ "$RC" -ne 0 ]; then
|
||||
echo "[filters-sweep] РЕЗУЛЬТАТ: КРАСНЫЙ (rc=$RC)"
|
||||
exit 1
|
||||
fi
|
||||
echo "[filters-sweep] РЕЗУЛЬТАТ: ЗЕЛЁНЫЙ"
|
||||
exit 0
|
||||
255
tests/e2e/filters-sweep.ts
Normal file
255
tests/e2e/filters-sweep.ts
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
/**
|
||||
* Filters-sweep: смысловая проверка фильтров списков (не «200 OK», а независимо
|
||||
* посчитанный ожидаемый набор строк / итог). Поднимает свежую temp-организацию,
|
||||
* сеет контролируемый набор товаров (с остатком / без / в минусе / услуга /
|
||||
* архивный / в 3-уровневой группе), затем для каждого контрола каждого списка
|
||||
* сверяет фактический ответ API с ожидаемым, посчитанным в этом же скрипте.
|
||||
*
|
||||
* Запуск: pnpm exec tsx filters-sweep.ts (или tests/e2e/filters-sweep.sh)
|
||||
* Env: E2E_ADMIN_URL (default https://test.admin.food-market.kz)
|
||||
* Выход: 0 — всё сошлось; 1 — есть расхождения (= баги фильтров).
|
||||
*/
|
||||
import { makeClient, signupOrg, ean13 } from './lib/fc.js'
|
||||
import { writeFileSync, mkdirSync } from 'node:fs'
|
||||
import type { AxiosInstance } from 'axios'
|
||||
|
||||
const results: { name: string; ok: boolean; detail: string }[] = []
|
||||
function check(name: string, ok: boolean, detail = '') {
|
||||
results.push({ name, ok, detail })
|
||||
console.log(`${ok ? '✓' : '✗'} ${name}${detail ? ' — ' + detail : ''}`)
|
||||
}
|
||||
function eqSet(a: string[], b: string[]) {
|
||||
const sa = new Set(a), sb = new Set(b)
|
||||
if (sa.size !== sb.size) return false
|
||||
for (const x of sa) if (!sb.has(x)) return false
|
||||
return true
|
||||
}
|
||||
|
||||
async function firstId(api: AxiosInstance, url: string, where?: (x: any) => boolean): Promise<string> {
|
||||
const r = await api.get(url)
|
||||
const items = Array.isArray(r.data) ? r.data : (r.data.items ?? [])
|
||||
const pick = where ? items.find(where) : items[0]
|
||||
if (!pick) throw new Error(`no item at ${url}`)
|
||||
return pick.id
|
||||
}
|
||||
|
||||
async function createGroup(api: AxiosInstance, name: string, parentId: string | null): Promise<string> {
|
||||
const r = await api.post('/api/catalog/product-groups', { name, parentId })
|
||||
if (r.status >= 300) throw new Error(`create group ${name}: ${r.status} ${JSON.stringify(r.data)}`)
|
||||
return r.data.id
|
||||
}
|
||||
|
||||
async function createProduct(api: AxiosInstance, opts: {
|
||||
name: string; article?: string; barcode?: string; groupId: string; price?: number;
|
||||
unitId: string; currencyId: string; priceTypeId: string; isService?: boolean;
|
||||
}): Promise<string> {
|
||||
const r = await api.post('/api/catalog/products', {
|
||||
name: opts.name, article: opts.article ?? null, description: null,
|
||||
unitOfMeasureId: opts.unitId, vat: 12, vatEnabled: true, productGroupId: opts.groupId,
|
||||
isService: opts.isService ?? false, packaging: 0, isMarked: false,
|
||||
referencePrice: (opts.price ?? 100) * 0.7, purchaseCurrencyId: opts.currencyId,
|
||||
prices: [{ priceTypeId: opts.priceTypeId, amount: opts.price ?? 100, currencyId: opts.currencyId }],
|
||||
barcodes: opts.barcode ? [{ code: opts.barcode, type: 0, isPrimary: true }] : [],
|
||||
})
|
||||
if (r.status >= 300) throw new Error(`create product ${opts.name}: ${r.status} ${JSON.stringify(r.data)}`)
|
||||
return r.data.id
|
||||
}
|
||||
|
||||
async function enterStock(api: AxiosInstance, storeId: string, currencyId: string, productId: string, qty: number) {
|
||||
const d = await api.post('/api/inventory/enters', {
|
||||
date: new Date().toISOString(), storeId, currencyId, notes: 'sweep',
|
||||
lines: [{ productId, quantity: qty, unitCost: 50 }],
|
||||
})
|
||||
if (d.status >= 300) throw new Error(`enter: ${d.status} ${JSON.stringify(d.data)}`)
|
||||
const p = await api.post(`/api/inventory/enters/${d.data.id}/post`, {})
|
||||
if (p.status >= 300) throw new Error(`enter post: ${p.status} ${JSON.stringify(p.data)}`)
|
||||
}
|
||||
|
||||
async function lossStock(api: AxiosInstance, storeId: string, currencyId: string, productId: string, qty: number) {
|
||||
const d = await api.post('/api/inventory/losses', {
|
||||
date: new Date().toISOString(), storeId, currencyId, reason: 0, notes: 'sweep-neg',
|
||||
lines: [{ productId, quantity: qty, unitCost: 50 }],
|
||||
})
|
||||
if (d.status >= 300) throw new Error(`loss: ${d.status} ${JSON.stringify(d.data)}`)
|
||||
const p = await api.post(`/api/inventory/losses/${d.data.id}/post`, {})
|
||||
if (p.status >= 300) throw new Error(`loss post: ${p.status} ${JSON.stringify(p.data)}`)
|
||||
}
|
||||
|
||||
/** Собирает все страницы списка, возвращает массив productId (или id). */
|
||||
async function allIds(api: AxiosInstance, url: string, idKey = 'productId'): Promise<string[]> {
|
||||
const ids: string[] = []
|
||||
let page = 1
|
||||
for (;;) {
|
||||
const sep = url.includes('?') ? '&' : '?'
|
||||
const r = await api.get(`${url}${sep}page=${page}&pageSize=100`)
|
||||
if (r.status >= 300) throw new Error(`${url}: ${r.status} ${JSON.stringify(r.data).slice(0, 200)}`)
|
||||
const items = r.data.items ?? []
|
||||
for (const it of items) ids.push(it[idKey] ?? it.id)
|
||||
const total = r.data.total ?? ids.length
|
||||
if (ids.length >= total || items.length === 0) break
|
||||
page++
|
||||
if (page > 50) break
|
||||
}
|
||||
return ids
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const sess = await signupOrg()
|
||||
const api = makeClient(sess.token)
|
||||
console.log(`[filters-sweep] temp-орг ${sess.email}`)
|
||||
|
||||
const unitId = await firstId(api, '/api/catalog/units-of-measure?pageSize=50')
|
||||
const storeId = await firstId(api, '/api/catalog/stores?pageSize=50')
|
||||
const store2 = await (async () => {
|
||||
const r = await api.post('/api/catalog/stores', { name: `Склад2-${Date.now()}`, code: null })
|
||||
return r.status < 300 ? r.data.id : null
|
||||
})()
|
||||
const currencyId = await firstId(api, '/api/catalog/currencies?pageSize=50', (c: any) => c.code === 'KZT')
|
||||
const priceTypeId = await firstId(api, '/api/catalog/price-types?pageSize=50')
|
||||
|
||||
// 3-уровневая группа: parent → child → grandchild.
|
||||
const gParent = await createGroup(api, `P-${Date.now()}`, null)
|
||||
const gChild = await createGroup(api, `C-${Date.now()}`, gParent)
|
||||
const gGrand = await createGroup(api, `G-${Date.now()}`, gChild)
|
||||
// Отдельная группа вне дерева (контроль, что фильтр не захватывает чужое).
|
||||
const gOther = await createGroup(api, `O-${Date.now()}`, null)
|
||||
|
||||
const art = `ART${Date.now() % 100000}`
|
||||
const bc = ean13(777001)
|
||||
const base = { unitId, currencyId, priceTypeId }
|
||||
const pGrandStock = await createProduct(api, { name: 'Товар-внук-состок', groupId: gGrand, ...base })
|
||||
const pChildZero = await createProduct(api, { name: 'Товар-ребёнок-ноль', groupId: gChild, ...base })
|
||||
const pParentNeg = await createProduct(api, { name: 'Товар-родитель-минус', groupId: gParent, ...base })
|
||||
const pSearch = await createProduct(api, { name: 'Товар-поиск', article: art, barcode: bc, groupId: gOther, ...base })
|
||||
const pService = await createProduct(api, { name: 'Услуга-доставка', groupId: gOther, isService: true, ...base })
|
||||
|
||||
// Остатки: внук +10 (store1), поиск +5 (store1), родитель 2 затем −3 (минус),
|
||||
// ребёнок — 0 (не приходуем). Услуга — без остатка.
|
||||
await enterStock(api, storeId, currencyId, pGrandStock, 10)
|
||||
await enterStock(api, storeId, currencyId, pSearch, 5)
|
||||
await enterStock(api, storeId, currencyId, pParentNeg, 2)
|
||||
await lossStock(api, storeId, currencyId, pParentNeg, 5) // → −3
|
||||
|
||||
// ожидаемые множества товаров (все не-услуги):
|
||||
const allProducts = [pGrandStock, pChildZero, pParentNeg, pSearch]
|
||||
const withStockAllStores = [pGrandStock, pSearch, pParentNeg] // qty != 0 (внук10, поиск5, родитель−3)
|
||||
const parentSubtree = [pGrandStock, pChildZero, pParentNeg] // в дереве parent
|
||||
const grandOnly = [pGrandStock]
|
||||
|
||||
const stockAllIncl = await allIds(api, '/api/inventory/stock?includeZero=true')
|
||||
|
||||
// ── Проверки остатков ───────────────────────────────────────────────
|
||||
check('stock: без includeZero = только ненулевые',
|
||||
eqSet(await allIds(api, '/api/inventory/stock'), withStockAllStores),
|
||||
`ожидали ${withStockAllStores.length}`)
|
||||
|
||||
check('stock: includeZero=true = все не-услуги',
|
||||
eqSet(stockAllIncl, allProducts),
|
||||
`получили ${stockAllIncl.length}`)
|
||||
|
||||
check('stock: услуга никогда не в остатках', !stockAllIncl.includes(pService))
|
||||
|
||||
check('stock: includeZero добавляет именно нулевой товар',
|
||||
stockAllIncl.includes(pChildZero) && !(await allIds(api, '/api/inventory/stock')).includes(pChildZero))
|
||||
|
||||
check('stock: negativeOnly=true = только минус',
|
||||
eqSet(await allIds(api, '/api/inventory/stock?negativeOnly=true'), [pParentNeg]))
|
||||
|
||||
check('stock: groupId=parent (+zero) = всё поддерево (3 уровня)',
|
||||
eqSet(await allIds(api, `/api/inventory/stock?groupId=${gParent}&includeZero=true`), parentSubtree))
|
||||
|
||||
check('stock: groupId=grandchild = только внук',
|
||||
eqSet(await allIds(api, `/api/inventory/stock?groupId=${gGrand}&includeZero=true`), grandOnly))
|
||||
|
||||
check('stock: productGroupId — синоним groupId',
|
||||
eqSet(await allIds(api, `/api/inventory/stock?productGroupId=${gParent}&includeZero=true`), parentSubtree))
|
||||
|
||||
check('stock: groupId без zero = только ненулевые из группы',
|
||||
eqSet(await allIds(api, `/api/inventory/stock?groupId=${gParent}`), [pGrandStock, pParentNeg]))
|
||||
|
||||
{
|
||||
const byArt = await allIds(api, `/api/inventory/stock?includeZero=true&search=${art}`)
|
||||
check('stock: поиск по артикулу', eqSet(byArt, [pSearch]))
|
||||
const byBc = await allIds(api, `/api/inventory/stock?includeZero=true&search=${bc}`)
|
||||
check('stock: поиск по штрихкоду', eqSet(byBc, [pSearch]))
|
||||
}
|
||||
|
||||
{
|
||||
// store-specific: store2 без остатков → includeZero покажет все (qty 0),
|
||||
// без флага — пусто.
|
||||
if (store2) {
|
||||
const s2zero = await allIds(api, `/api/inventory/stock?storeId=${store2}`)
|
||||
check('stock: другой склад без остатков и без zero = пусто', s2zero.length === 0,
|
||||
`получили ${s2zero.length}`)
|
||||
const s2all = await allIds(api, `/api/inventory/stock?storeId=${store2}&includeZero=true`)
|
||||
check('stock: другой склад + includeZero = все товары с 0',
|
||||
eqSet(s2all, allProducts))
|
||||
}
|
||||
}
|
||||
|
||||
{
|
||||
// сортировка по количеству (все склады, includeZero) — не убывает по возрастанию
|
||||
const r = await api.get('/api/inventory/stock?includeZero=true&sort=quantity&order=asc&pageSize=100')
|
||||
const qs = (r.data.items ?? []).map((x: any) => x.quantity)
|
||||
let mono = true
|
||||
for (let i = 1; i < qs.length; i++) if (qs[i] < qs[i - 1]) mono = false
|
||||
check('stock: сортировка по количеству (asc) монотонна', mono, `[${qs.join(',')}]`)
|
||||
}
|
||||
|
||||
{
|
||||
// пагинация: pageSize=2 разбивает полный набор без пересечений/пропусков
|
||||
const full = await allIds(api, '/api/inventory/stock?includeZero=true')
|
||||
const p1 = (await api.get('/api/inventory/stock?includeZero=true&page=1&pageSize=2')).data
|
||||
const p2 = (await api.get('/api/inventory/stock?includeZero=true&page=2&pageSize=2')).data
|
||||
const ids1 = (p1.items ?? []).map((x: any) => x.productId)
|
||||
const ids2 = (p2.items ?? []).map((x: any) => x.productId)
|
||||
const overlap = ids1.some((x: string) => ids2.includes(x))
|
||||
check('stock: пагинация без пересечений', !overlap && ids1.length === 2 && ids2.length === 2,
|
||||
`page1=${ids1.length} page2=${ids2.length} total=${p1.total}`)
|
||||
check('stock: total пагинации = полному набору', p1.total === full.length, `${p1.total} vs ${full.length}`)
|
||||
}
|
||||
|
||||
{
|
||||
// экспорт с фильтром includeZero должен отдать все строки (CSV)
|
||||
const r = await api.get('/api/inventory/stock/export?includeZero=true&format=csv')
|
||||
const lines = String(r.data).trim().split('\n').filter(Boolean)
|
||||
const dataRows = lines.length - 1 // минус заголовок
|
||||
check('stock/export: includeZero отдаёт все товары',
|
||||
dataRows === allProducts.length, `строк ${dataRows}`)
|
||||
}
|
||||
|
||||
// ── Проверки каталога ───────────────────────────────────────────────
|
||||
check('catalog: groupId=parent = поддерево',
|
||||
eqSet(await allIds(api, `/api/catalog/products?groupId=${gParent}`, 'id'), parentSubtree))
|
||||
check('catalog: productGroupId — синоним (раньше молча игнорировался)',
|
||||
eqSet(await allIds(api, `/api/catalog/products?productGroupId=${gParent}`, 'id'), parentSubtree))
|
||||
check('catalog: groupId=grandchild = только внук',
|
||||
eqSet(await allIds(api, `/api/catalog/products?groupId=${gGrand}`, 'id'), grandOnly))
|
||||
{
|
||||
const services = await allIds(api, '/api/catalog/products?isService=true', 'id')
|
||||
check('catalog: isService=true = только услуги', eqSet(services, [pService]))
|
||||
}
|
||||
|
||||
// ── отчёт ───────────────────────────────────────────────────────────
|
||||
const fails = results.filter(r => !r.ok)
|
||||
const ts = new Date().toISOString().replace(/[:.]/g, '-')
|
||||
mkdirSync('reports', { recursive: true })
|
||||
const md = [
|
||||
`# Filters-sweep ${ts}`,
|
||||
``,
|
||||
`Стенд: ${process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'} · орг: ${sess.email}`,
|
||||
``,
|
||||
`**Итог:** ${results.filter(r => r.ok).length} ✓ / ${fails.length} ✗`,
|
||||
``,
|
||||
`| | Проверка | Детали |`,
|
||||
`|---|---|---|`,
|
||||
...results.map(r => `| ${r.ok ? '✓' : '✗'} | ${r.name} | ${r.detail} |`),
|
||||
].join('\n')
|
||||
const path = `reports/filters-sweep-${ts}.md`
|
||||
writeFileSync(path, md)
|
||||
console.log(`\n[filters-sweep] отчёт: ${path}`)
|
||||
console.log(`[filters-sweep] ${results.filter(r => r.ok).length} ✓ / ${fails.length} ✗`)
|
||||
process.exit(fails.length > 0 ? 1 : 0)
|
||||
}
|
||||
|
||||
main().catch(e => { console.error('[filters-sweep] FATAL', e?.message ?? e); process.exit(2) })
|
||||
Loading…
Reference in a new issue