diff --git a/docs/filters-sweep-progress.md b/docs/filters-sweep-progress.md index 8b547c0..b42c35e 100644 --- a/docs/filters-sweep-progress.md +++ b/docs/filters-sweep-progress.md @@ -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-.md - [ ] Матрица покрытия: что уже зелёное (не перепроверять) vs новое. diff --git a/tests/e2e/filters-sweep.sh b/tests/e2e/filters-sweep.sh new file mode 100755 index 0000000..30dce36 --- /dev/null +++ b/tests/e2e/filters-sweep.sh @@ -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 diff --git a/tests/e2e/filters-sweep.ts b/tests/e2e/filters-sweep.ts new file mode 100644 index 0000000..bf968a8 --- /dev/null +++ b/tests/e2e/filters-sweep.ts @@ -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 { + 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 { + 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 { + 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 { + 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) })