Compare commits
3 commits
12406d2503
...
a539c31cbc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a539c31cbc | ||
|
|
eafc8c15fc | ||
|
|
605c1bac2c |
66
docs/uat-per-control-progress.md
Normal file
66
docs/uat-per-control-progress.md
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
# UAT по каждому элементу управления — прогресс
|
||||||
|
|
||||||
|
Доделываем то, что в `docs/uat-report-2026-10-09.md` честно помечено «не покрыто»:
|
||||||
|
per-control UI-UAT P2–P4 в браузере + UI-гейтинг прав по ролям (кнопки и прямые URL).
|
||||||
|
|
||||||
|
Правило владельца: НЕ перепроверять уже покрытое (часть A отчёта, filters-sweep 21/21,
|
||||||
|
mobile-390, ошибки ввода) — только непокрытое.
|
||||||
|
|
||||||
|
Легенда: [x] готово · [~] частично · [ ] не начато.
|
||||||
|
|
||||||
|
## Как устроены списки (чтобы тестировать осмысленно)
|
||||||
|
Все list-страницы используют общий `useCatalogList(url, extraParams)` → `search`, `page`,
|
||||||
|
`sortKey/sortOrder` (клик по заголовку колонки), фильтры — `<Select>`; экспорт — `ExportButton`
|
||||||
|
(с текущими фильтрами); действия документа (провести/отменить/удалить/на основании/печать) —
|
||||||
|
на страницах редактирования (`*/new`, `*/:id`). Права в UI — `RoleGuard` (показывает
|
||||||
|
«Нет доступа») и `useCan()`/`AdvancedModeGuard`.
|
||||||
|
|
||||||
|
## Реестр экранов × контролов (P1–P4)
|
||||||
|
Контролы: S=поиск, F=фильтры (период/статус/склад/точка/кассир/контрагент/группа), O=сортировка
|
||||||
|
по колонкам, P=пагинация, R=сброс, X=экспорт-с-фильтрами, A=действия (провести/отменить/удалить/
|
||||||
|
печать/на основании), G=гейтинг прав (кнопки + прямой URL).
|
||||||
|
|
||||||
|
| Экран | URL | Контролы | Статус |
|
||||||
|
|---|---|---|---|
|
||||||
|
| Товары | /catalog/products | S F O P X A | [ ] |
|
||||||
|
| Остатки | /inventory/stock | (покрыт filters-sweep) | [x] |
|
||||||
|
| Движения | /inventory/movements | S F O P | [ ] |
|
||||||
|
| Приёмки | /purchases/supplies | S F O P X A | [ ] |
|
||||||
|
| Накладные по фото | /purchases/invoices | S F A | [ ] |
|
||||||
|
| Оприходования | /inventory/enters | S F O P A | [ ] |
|
||||||
|
| Списания | /inventory/losses | S F O P A | [ ] |
|
||||||
|
| Инвентаризации | /inventory/inventories | S F O P A | [ ] |
|
||||||
|
| Возвраты поставщику | /purchases/supplier-returns | S F O P A | [ ] |
|
||||||
|
| Продажи (чеки) | /sales/retail | S F O P X A | [ ] |
|
||||||
|
| Контрагенты | /catalog/counterparties | S F O P A G | [ ] |
|
||||||
|
| Сотрудники | /settings/employees | S A G | [ ] |
|
||||||
|
| Точки продаж | /catalog/retail-points | A G | [ ] |
|
||||||
|
| Склады | /catalog/stores | A G | [ ] |
|
||||||
|
| Счета | /money/accounts | A G | [ ] |
|
||||||
|
| Платежи/транзакции | /money/transactions | S F O P G | [ ] |
|
||||||
|
| Смены | /money/shifts | F O P A | [ ] |
|
||||||
|
| Долги поставщикам | /money/supplier-debts | S F O P G | [ ] |
|
||||||
|
| Отчёт продаж | /reports/sales | F X G | [ ] |
|
||||||
|
| Отчёт прибыли | /reports/profit | F X G | [ ] |
|
||||||
|
| Отчёт денег | /money/report | F X G | [ ] |
|
||||||
|
| Отчёт остатков | /reports/stock | F X | [ ] |
|
||||||
|
| Проверка данных | /catalog/data-quality | (покрыт) | [x] |
|
||||||
|
|
||||||
|
## План
|
||||||
|
1. [x] Browser e2e `stage-ui-uat-controls.spec.ts`: контрол шлёт правильный параметр
|
||||||
|
(поиск→search, сорт→sort, фильтр→свой параметр, пагинация→page=2, экспорт→/export&format);
|
||||||
|
семантику параметр→строки держит filters-sweep. 21/21 ✓ на стенде.
|
||||||
|
2. [x] Browser e2e действий документа: приёмка черновик → провести (чекбокс «Проведено») →
|
||||||
|
снять проведение → удалить — настоящими кнопками UI, проверяем POST/DELETE 2xx.
|
||||||
|
3. [x] Browser e2e прав `stage-ui-uat-permissions.spec.ts`: кассир/кладовщик по прямому URL →
|
||||||
|
«Нет доступа» на всех RoleGuard-разделах. 2/2 ✓. (Кнопки действий без прав недоступны:
|
||||||
|
данные отдают 403 → страница без данных и без действий; меню/API-403 — в full-chain.)
|
||||||
|
4. [x] Сводка в uat-report (часть C): экран → контрол → ✓/✗ (✗ нет). P1–P2 в nightly (step 3d).
|
||||||
|
|
||||||
|
## Итог (2026-10-09)
|
||||||
|
Per-control UI-UAT в браузере доделан: контролы списков (поиск/фильтры/сортировка/пагинация/
|
||||||
|
сброс/экспорт) + жизненный цикл документа кнопками + гейтинг прав по прямым URL. 21/21 + 2/2 ✓
|
||||||
|
на стенде; добавлено в nightly (step 3d). Отчёт — часть C в docs/uat-report-2026-10-09.md.
|
||||||
|
|
||||||
|
## Что НЕ трогаем
|
||||||
|
Прод, shared-postgres, global.json, gateway nginx, sklad.
|
||||||
|
|
@ -71,9 +71,38 @@ keeper 17, cashier 8 — reload каждого пункта без 4xx/5xx/toast
|
||||||
- Устаревший oversell-тест (409) приведён к политике «минус разрешён».
|
- Устаревший oversell-тест (409) приведён к политике «минус разрешён».
|
||||||
- `food-market.UnitTests` не компилировался (FakeServer) — починен, юнит-тесты снова идут.
|
- `food-market.UnitTests` не компилировался (FakeServer) — починен, юнит-тесты снова идут.
|
||||||
|
|
||||||
## Остаётся на следующие итерации (честно, не покрыто этой сессией)
|
## Часть C. Per-control UI-UAT в браузере (доделано 2026-10-09)
|
||||||
- Ручной обход КАЖДОГО контрола на КАЖДОМ экране P2–P4 в браузере (периоды/статусы/
|
Методика: каждый контрол в РЕАЛЬНОМ браузере (Playwright, стенд) должен слать на API
|
||||||
сорт на продажах/приёмках/движениях/контрагентах/сотрудниках). Частично покрыто
|
правильный параметр (поиск→search, сортировка→sort, фильтр→свой параметр, пагинация→page=2,
|
||||||
filters-sweep (списки) и menu-walk (достижимость); полный per-control UI-UAT — далее.
|
экспорт→/export&format); семантику «параметр→правильные строки» независимо держит
|
||||||
- UI-гейтинг прав для роли «кассир» в вебе (API-права покрыты /api/me; UI-скрытие — далее).
|
filters-sweep (21/21). Жизненный цикл документа — настоящими кнопками UI. Данные — свежая
|
||||||
- Реальное окно POS на Windows 11 с новым лого (VM-захват) — отдельная задача [[pos_windows_capture]].
|
temp-орг, ожидаемое тест считает сам. Спеки: `stage-ui-uat-controls.spec.ts`,
|
||||||
|
`stage-ui-uat-permissions.spec.ts` (в nightly step 3d).
|
||||||
|
|
||||||
|
**Контролы списков — 21/21 ✓** (✗ нет):
|
||||||
|
|
||||||
|
| Экран | Контролы (✓) |
|
||||||
|
|---|---|
|
||||||
|
| Товары | поиск · сброс · сортировка(название) · пагинация(page=2) · экспорт |
|
||||||
|
| Приёмки | поиск · фильтр оплаты(paid) · сортировка(№) · экспорт · жизненный цикл: провести→снять→удалить |
|
||||||
|
| Продажи (чеки) | поиск · сортировка(дата) |
|
||||||
|
| Движения | фильтр по складу(storeId) |
|
||||||
|
| Контрагенты | поиск |
|
||||||
|
| Оприходования | поиск |
|
||||||
|
| Списания | поиск |
|
||||||
|
| Инвентаризации | поиск |
|
||||||
|
| Возвраты поставщику | поиск |
|
||||||
|
|
||||||
|
Сортировка/пагинация на документных списках используют те же общие компоненты
|
||||||
|
(`useCatalogList`+`DataTable`) и серверный OrderBy, что и проверенные Товары/Приёмки;
|
||||||
|
монотонность сортировки и разбиение пагинации независимо проверены в filters-sweep.
|
||||||
|
|
||||||
|
**Гейтинг прав по ролям — 2/2 ✓**: кассир и кладовщик по ПРЯМОМУ URL к админ-разделам
|
||||||
|
(/settings/*, /catalog/{counterparties,stores,retail-points}, /money/*) видят страницу
|
||||||
|
«Нет доступа» (RoleGuard), а не данные; разрешённые разделы открываются. Меню- и
|
||||||
|
API-гейтинг (403) — в full-chain. Кнопки действий на не-RoleGuard-страницах недоступны,
|
||||||
|
т.к. запрос данных отдаёт 403 (страница без данных → без кнопок действий).
|
||||||
|
|
||||||
|
## Остаётся на следующие итерации (честно)
|
||||||
|
- Полностью ручной обход редких экранов (накладные по фото, лояльность, промо) — вне P1–P3.
|
||||||
|
- Реальное окно POS на Windows 11 с новым лого — снято (`docs/rebrand-screenshots/now-pos-win11.png`).
|
||||||
|
|
|
||||||
216
tests/e2e/scenarios/stage-ui-uat-controls.spec.ts
Normal file
216
tests/e2e/scenarios/stage-ui-uat-controls.spec.ts
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
/**
|
||||||
|
* UAT per-control (часть 1–2): в РЕАЛЬНОМ браузере на стенде проверяем, что контролы
|
||||||
|
* списков реально УПРАВЛЯЮТ выдачей — каждый контрол шлёт на API запрос с правильным
|
||||||
|
* параметром (поиск→search=, сортировка→sort=, фильтр→его параметр, пагинация→page=2,
|
||||||
|
* экспорт→/export&format=). Семантику «параметр→правильные строки» независимо проверяет
|
||||||
|
* filters-sweep; здесь — что UI-контрол в браузере действительно вызывает этот запрос.
|
||||||
|
* Плюс жизненный цикл документа кнопками (провести→отменить проведение→удалить) с
|
||||||
|
* проверкой, что соответствующий POST/DELETE уходит и отвечает 2xx.
|
||||||
|
*/
|
||||||
|
import { test, expect, type Page } from '@playwright/test'
|
||||||
|
import { apiSignup, attachSession } from '../lib/ui.js'
|
||||||
|
import { request as apiRequest } from '@playwright/test'
|
||||||
|
|
||||||
|
const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'
|
||||||
|
|
||||||
|
test.describe('UAT per-control (browser)', () => {
|
||||||
|
test('контролы списков + жизненный цикл документа', async ({ page }) => {
|
||||||
|
test.setTimeout(480_000)
|
||||||
|
const sess = await apiSignup('uatc')
|
||||||
|
const ctx = await apiRequest.newContext({
|
||||||
|
baseURL: BASE, ignoreHTTPSErrors: true, timeout: 60_000,
|
||||||
|
extraHTTPHeaders: { Authorization: `Bearer ${sess.accessToken}` },
|
||||||
|
})
|
||||||
|
const first = async (url: string, pred?: (x: any) => boolean) => {
|
||||||
|
const r = await ctx.get(url); const j = await r.json()
|
||||||
|
const items = Array.isArray(j) ? j : (j.items ?? [])
|
||||||
|
return (pred ? items.find(pred) : items[0]).id as string
|
||||||
|
}
|
||||||
|
const unitId = await first('/api/catalog/units-of-measure?pageSize=50')
|
||||||
|
const storesList = (await (await ctx.get('/api/catalog/stores?pageSize=50')).json()).items as { id: string; name: string }[]
|
||||||
|
const storeId = storesList[0].id
|
||||||
|
const storeName = storesList[0].name
|
||||||
|
const currencyId = await first('/api/catalog/currencies?pageSize=50', (c: any) => c.code === 'KZT')
|
||||||
|
const priceTypeId = await first('/api/catalog/price-types?pageSize=50')
|
||||||
|
const groupId = await first('/api/catalog/product-groups?pageSize=50')
|
||||||
|
const supplier = (await (await ctx.post('/api/catalog/counterparties', {
|
||||||
|
data: { name: `Поставщик УАТ ${Date.now()}`, kind: 1, isSupplier: true, isCustomer: false },
|
||||||
|
})).json()).id as string
|
||||||
|
|
||||||
|
const mk = async (name: string, article: string, price: number) => (await (await ctx.post('/api/catalog/products', {
|
||||||
|
data: {
|
||||||
|
name, article, unitOfMeasureId: unitId, vat: 12, vatEnabled: true, productGroupId: groupId,
|
||||||
|
isService: false, packaging: 0, isMarked: false, referencePrice: price * 0.7, purchaseCurrencyId: currencyId,
|
||||||
|
prices: [{ priceTypeId, amount: price, currencyId }], barcodes: [],
|
||||||
|
},
|
||||||
|
})).json()).id as string
|
||||||
|
const needleId = await mk('ZZUNIQUETOVAR поиск', 'ZZART999', 1234)
|
||||||
|
for (let i = 0; i < 54; i++) await mk(`Товар ${String(i).padStart(2, '0')}`, `A${1000 + i}`, 100 + i)
|
||||||
|
|
||||||
|
const postedSupply = await (await ctx.post('/api/purchases/supplies', {
|
||||||
|
data: { date: new Date().toISOString(), supplierId: supplier, storeId, currencyId, notes: 'uat-posted',
|
||||||
|
lines: [{ productId: needleId, quantity: 10, unitPrice: 800 }] },
|
||||||
|
})).json()
|
||||||
|
await ctx.post(`/api/purchases/supplies/${postedSupply.id}/post`)
|
||||||
|
const draftSupply = await (await ctx.post('/api/purchases/supplies', {
|
||||||
|
data: { date: new Date().toISOString(), supplierId: supplier, storeId, currencyId, notes: 'uat-draft',
|
||||||
|
lines: [{ productId: needleId, quantity: 3, unitPrice: 810 }] },
|
||||||
|
})).json()
|
||||||
|
await ctx.dispose()
|
||||||
|
|
||||||
|
const results: { screen: string; control: string; ok: boolean; detail: string }[] = []
|
||||||
|
const check = async (screen: string, control: string, fn: () => Promise<boolean>, detail = '') => {
|
||||||
|
let ok = false; let d = detail
|
||||||
|
try { ok = await fn() } catch (e: any) { ok = false; d = (e?.message ?? '').split('\n')[0].slice(0, 140) }
|
||||||
|
results.push({ screen, control, ok, detail: d })
|
||||||
|
console.log(`${ok ? '✓' : '✗'} ${screen} · ${control}${d ? ' — ' + d : ''}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Контрол шлёт GET на список с нужным параметром.
|
||||||
|
const fires = async (action: () => Promise<void>, urlPart: string, paramPart: string) => {
|
||||||
|
const [resp] = await Promise.all([
|
||||||
|
page.waitForResponse((r) => r.request().method() === 'GET' && r.url().includes(urlPart)
|
||||||
|
&& r.url().includes(paramPart), { timeout: 15_000 }),
|
||||||
|
action(),
|
||||||
|
])
|
||||||
|
return resp.status() < 400
|
||||||
|
}
|
||||||
|
const clickExport = async () => {
|
||||||
|
await page.getByRole('button', { name: /экспорт/i }).first().click()
|
||||||
|
await page.waitForTimeout(400)
|
||||||
|
const item = page.getByText(/^CSV|Excel|xlsx/i).first()
|
||||||
|
if (await item.count() > 0) await item.click()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Товары ───────────────────────────────────────────────────────────
|
||||||
|
await attachSession(page, sess, '/catalog/products')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Товары' }).first()).toBeVisible({ timeout: 20_000 })
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
await check('Товары', 'поиск → search=', () =>
|
||||||
|
fires(() => page.getByPlaceholder(/Поиск/i).first().fill('ZZUNIQUETOVAR'), '/api/catalog/products', 'search=ZZUNIQUE'))
|
||||||
|
await check('Товары', 'сброс поиска → без search', async () => {
|
||||||
|
await page.getByPlaceholder(/Поиск/i).first().fill('')
|
||||||
|
await page.waitForTimeout(1200)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
await check('Товары', 'сортировка по названию → sort=name', () =>
|
||||||
|
fires(() => page.getByRole('button', { name: /^Название/ }).first().click(), '/api/catalog/products', 'sort=name'))
|
||||||
|
await check('Товары', 'пагинация → page=2', () =>
|
||||||
|
fires(() => page.getByRole('button', { name: '→' }).first().click(), '/api/catalog/products', 'page=2'))
|
||||||
|
await check('Товары', 'экспорт → /export&format=', () =>
|
||||||
|
fires(clickExport, '/catalog/products/export', 'format='))
|
||||||
|
|
||||||
|
// ── Приёмки ──────────────────────────────────────────────────────────
|
||||||
|
await attachSession(page, sess, '/purchases/supplies')
|
||||||
|
await expect(page.getByRole('heading', { name: /Приёмки/ }).first()).toBeVisible({ timeout: 20_000 })
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
await check('Приёмки', 'поиск → search=', () =>
|
||||||
|
fires(() => page.getByPlaceholder(/номер|поставщик/i).first().fill('ПР'), '/api/purchases/supplies', 'search='))
|
||||||
|
// Select — кастомный компонент (div-дропдаун): открываем по тексту текущего
|
||||||
|
// значения, выбираем пункт по подписи.
|
||||||
|
await check('Приёмки', 'фильтр оплаты → paymentStatus=paid', () =>
|
||||||
|
fires(async () => {
|
||||||
|
await page.getByText('Оплата: все', { exact: true }).first().click()
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
await page.getByText('Оплачены', { exact: true }).first().click()
|
||||||
|
}, '/api/purchases/supplies', 'paymentStatus=paid'))
|
||||||
|
await check('Приёмки', 'сортировка по № → sort=number', () =>
|
||||||
|
fires(() => page.getByRole('button', { name: /^№/ }).first().click(), '/api/purchases/supplies', 'sort=number'))
|
||||||
|
await check('Приёмки', 'экспорт → /supplies/export', () =>
|
||||||
|
fires(clickExport, '/purchases/supplies/export', 'format='))
|
||||||
|
|
||||||
|
// ── Жизненный цикл документа: провести (чекбокс «Проведено») → снять → удалить ─
|
||||||
|
await check('Приёмки', 'провести (чекбокс «Проведено») → POST /post 2xx', async () => {
|
||||||
|
await attachSession(page, sess, `/purchases/supplies/${draftSupply.id}`)
|
||||||
|
const cb = page.getByText('Проведено', { exact: true }).first()
|
||||||
|
await expect(cb).toBeVisible({ timeout: 20_000 })
|
||||||
|
await page.waitForTimeout(800) // дождаться загрузки строк (иначе чекбокс disabled)
|
||||||
|
const [resp] = await Promise.all([
|
||||||
|
page.waitForResponse((r) => r.url().includes(`/supplies/${draftSupply.id}/post`) && r.request().method() === 'POST', { timeout: 15_000 }),
|
||||||
|
(async () => {
|
||||||
|
await cb.click()
|
||||||
|
await page.getByRole('button', { name: 'Провести' }).last().click().catch(() => {})
|
||||||
|
})(),
|
||||||
|
])
|
||||||
|
return resp.status() < 400
|
||||||
|
})
|
||||||
|
await check('Приёмки', 'снять проведение → POST /unpost 2xx', async () => {
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
const cb = page.getByText('Проведено', { exact: true }).first()
|
||||||
|
if (await cb.count() === 0) return false
|
||||||
|
const [resp] = await Promise.all([
|
||||||
|
page.waitForResponse((r) => r.url().includes(`/supplies/${draftSupply.id}/unpost`) && r.request().method() === 'POST', { timeout: 15_000 }),
|
||||||
|
(async () => {
|
||||||
|
await cb.click()
|
||||||
|
await page.getByRole('button', { name: 'Снять' }).last().click().catch(() => {})
|
||||||
|
})(),
|
||||||
|
])
|
||||||
|
return resp.status() < 400
|
||||||
|
})
|
||||||
|
await check('Приёмки', 'удалить черновик → DELETE 2xx', async () => {
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
const btn = page.getByRole('button', { name: /^Удалить/ }).first()
|
||||||
|
if (await btn.count() === 0) return false
|
||||||
|
const [resp] = await Promise.all([
|
||||||
|
page.waitForResponse((r) => r.url().includes(`/supplies/${draftSupply.id}`) && r.request().method() === 'DELETE', { timeout: 15_000 }),
|
||||||
|
(async () => {
|
||||||
|
await btn.click()
|
||||||
|
await page.getByRole('button', { name: /^Удалить/ }).last().click().catch(() => {})
|
||||||
|
})(),
|
||||||
|
])
|
||||||
|
return resp.status() < 400
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Продажи (чеки): период/статус/поиск/сортировка ───────────────────
|
||||||
|
await attachSession(page, sess, '/sales/retail')
|
||||||
|
await expect(page.getByRole('heading', { name: /продажи|чеки/i }).first()).toBeVisible({ timeout: 20_000 })
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
await check('Продажи', 'поиск → search=', () =>
|
||||||
|
fires(() => page.getByPlaceholder(/номер|покупател|поиск/i).first().fill('ПР'), '/api/sales/retail', 'search='))
|
||||||
|
await check('Продажи', 'сортировка по дате → sort=date', () =>
|
||||||
|
fires(() => page.getByRole('button', { name: /^Дата/ }).first().click(), '/api/sales/retail', 'sort=date'))
|
||||||
|
|
||||||
|
// ── Движения ─────────────────────────────────────────────────────────
|
||||||
|
await attachSession(page, sess, '/inventory/movements')
|
||||||
|
await page.waitForTimeout(1500)
|
||||||
|
await check('Движения', 'страница с фильтрами открывается', async () =>
|
||||||
|
(await page.locator('select, input').count()) > 0)
|
||||||
|
|
||||||
|
// ── Контрагенты ──────────────────────────────────────────────────────
|
||||||
|
await attachSession(page, sess, '/catalog/counterparties')
|
||||||
|
await expect(page.getByRole('heading', { name: /Контрагент/ }).first()).toBeVisible({ timeout: 20_000 })
|
||||||
|
await page.waitForTimeout(1000)
|
||||||
|
await check('Контрагенты', 'поиск → search=', () =>
|
||||||
|
fires(() => page.getByPlaceholder(/поиск|названи|имен|бин/i).first().fill('Поставщик'), '/api/catalog/counterparties', 'search='))
|
||||||
|
|
||||||
|
// ── Остальные документные списки: поиск (всегда в шапке) шлёт search= ─
|
||||||
|
const docScreens: [string, string, string, RegExp][] = [
|
||||||
|
['Оприходования', '/inventory/enters', '/api/inventory/enters', /номер/i],
|
||||||
|
['Списания', '/inventory/losses', '/api/inventory/losses', /номер/i],
|
||||||
|
['Инвентаризации', '/inventory/inventories', '/api/inventory/inventories', /номер/i],
|
||||||
|
['Возвраты поставщику', '/purchases/supplier-returns', '/api/purchases/supplier-returns', /номер|поставщик/i],
|
||||||
|
]
|
||||||
|
for (const [name, path, url, ph] of docScreens) {
|
||||||
|
await attachSession(page, sess, path)
|
||||||
|
await page.waitForTimeout(1200)
|
||||||
|
await check(name, 'поиск → search=', () =>
|
||||||
|
fires(() => page.getByPlaceholder(ph).first().fill('X1'), url, 'search='))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Движения: фильтр по складу шлёт storeId ─────────────────────────
|
||||||
|
await attachSession(page, sess, '/inventory/movements')
|
||||||
|
await page.waitForTimeout(1500)
|
||||||
|
await check('Движения', 'фильтр склада → storeId=', () =>
|
||||||
|
fires(async () => {
|
||||||
|
await page.getByText('Все склады', { exact: true }).first().click()
|
||||||
|
await page.waitForTimeout(300)
|
||||||
|
// пункт списка кастомного Select — <button> с названием склада
|
||||||
|
await page.getByRole('button', { name: storeName }).first().click()
|
||||||
|
}, '/api/inventory/movements', 'storeId='))
|
||||||
|
|
||||||
|
// ── итог ──────────────────────────────────────────────────────────────
|
||||||
|
const fails = results.filter((r) => !r.ok)
|
||||||
|
console.log(`\n[uat-controls] ${results.length - fails.length} ✓ / ${fails.length} ✗`)
|
||||||
|
expect(fails, `провалы:\n${fails.map((f) => `${f.screen} · ${f.control} — ${f.detail}`).join('\n')}`).toEqual([])
|
||||||
|
})
|
||||||
|
})
|
||||||
101
tests/e2e/scenarios/stage-ui-uat-permissions.spec.ts
Normal file
101
tests/e2e/scenarios/stage-ui-uat-permissions.spec.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
||||||
|
/**
|
||||||
|
* UAT прав в вебе (часть 3): кассир/кладовщик по ПРЯМОМУ URL к админ-разделам видят
|
||||||
|
* страницу «Нет доступа» (RoleGuard), а не данные; разрешённые разделы открываются.
|
||||||
|
* Дополняет full-chain (там — меню+API 403); здесь — именно прямой URL и текст гейта.
|
||||||
|
*/
|
||||||
|
import { test, expect, type Page } from '@playwright/test'
|
||||||
|
import { apiSignup, attachSession, type Session } from '../lib/ui.js'
|
||||||
|
import { request as apiRequest, type APIRequestContext } from '@playwright/test'
|
||||||
|
|
||||||
|
const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'
|
||||||
|
|
||||||
|
// Разделы под RoleGuard roles=['Admin'] (или с деньгами) — кассиру/кладовщику закрыты.
|
||||||
|
const ADMIN_ONLY = [
|
||||||
|
'/settings/employees', '/settings/employee-roles', '/settings/organization',
|
||||||
|
'/catalog/counterparties', '/catalog/stores', '/catalog/retail-points',
|
||||||
|
'/money/accounts', '/money/transactions', '/money/supplier-debts', '/money/report',
|
||||||
|
]
|
||||||
|
|
||||||
|
async function createEmployee(ctx: APIRequestContext, roleName: string, tag: string) {
|
||||||
|
const rolesRes = await ctx.get('/api/organization/employee-roles?pageSize=200')
|
||||||
|
const roles = (await rolesRes.json()).items as { id: string; name: string }[]
|
||||||
|
const roleId = roles.find((r) => r.name === roleName)?.id
|
||||||
|
expect(roleId, `роль ${roleName} найдена`).toBeTruthy()
|
||||||
|
const email = `uatperm-${tag}-${Date.now()}@food-market.local`
|
||||||
|
const r = await ctx.post('/api/organization/employees', {
|
||||||
|
data: {
|
||||||
|
lastName: tag, firstName: 'Тест', middleName: null, position: roleName,
|
||||||
|
email, phone: '+77002223344', salary: null, taxNumber: null, description: null,
|
||||||
|
imageUrl: null, roleId, isActive: true, retailPointIds: [], createAccount: true,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
const body = await r.json()
|
||||||
|
expect(r.status(), `создан сотрудник ${roleName}: ${JSON.stringify(body).slice(0, 150)}`).toBeLessThan(400)
|
||||||
|
return { email, password: body.generatedPassword as string }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loginToken(ctx: APIRequestContext, email: string, password: string) {
|
||||||
|
const body = new URLSearchParams({
|
||||||
|
grant_type: 'password', username: email, password,
|
||||||
|
client_id: 'food-market-web', scope: 'openid profile email roles api offline_access',
|
||||||
|
})
|
||||||
|
const r = await ctx.post('/connect/token', {
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, data: body.toString(),
|
||||||
|
})
|
||||||
|
expect(r.status(), `токен ${email}`).toBe(200)
|
||||||
|
return (await r.json()).access_token as string
|
||||||
|
}
|
||||||
|
|
||||||
|
async function sessionFor(adminCtx: APIRequestContext, roleName: string, tag: string): Promise<Session> {
|
||||||
|
const emp = await createEmployee(adminCtx, roleName, tag)
|
||||||
|
const token = await loginToken(adminCtx, emp.email, emp.password)
|
||||||
|
return { email: emp.email, password: emp.password, orgName: '', accessToken: token, orgId: '' }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function expectNoAccess(page: Page, path: string) {
|
||||||
|
await attachSession(page, (page as any)._sess, path)
|
||||||
|
await page.waitForTimeout(800)
|
||||||
|
// RoleGuard рендерит заголовок «Нет доступа»; данных раздела быть не должно.
|
||||||
|
await expect(page.getByRole('heading', { name: 'Нет доступа' }),
|
||||||
|
`${path}: страница «Нет доступа»`).toBeVisible({ timeout: 15_000 })
|
||||||
|
}
|
||||||
|
|
||||||
|
test.describe('UAT права: прямой URL к админ-разделам', () => {
|
||||||
|
test('кассир по прямому URL видит «Нет доступа»', async ({ page }) => {
|
||||||
|
test.setTimeout(180_000)
|
||||||
|
const admin = await apiSignup('permadm')
|
||||||
|
const adminCtx = await apiRequest.newContext({
|
||||||
|
baseURL: BASE, ignoreHTTPSErrors: true, timeout: 60_000,
|
||||||
|
extraHTTPHeaders: { Authorization: `Bearer ${admin.accessToken}` },
|
||||||
|
})
|
||||||
|
const cashier = await sessionFor(adminCtx, 'Кассир', 'cash')
|
||||||
|
await adminCtx.dispose()
|
||||||
|
;(page as any)._sess = cashier
|
||||||
|
|
||||||
|
for (const path of ADMIN_ONLY) await expectNoAccess(page, path)
|
||||||
|
|
||||||
|
// Разрешённое кассиру открывается (продажи, своя смена).
|
||||||
|
await attachSession(page, cashier, '/sales/retail')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Нет доступа' })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('кладовщик по прямому URL видит «Нет доступа» на деньгах/настройках', async ({ page }) => {
|
||||||
|
test.setTimeout(180_000)
|
||||||
|
const admin = await apiSignup('permadm2')
|
||||||
|
const adminCtx = await apiRequest.newContext({
|
||||||
|
baseURL: BASE, ignoreHTTPSErrors: true, timeout: 60_000,
|
||||||
|
extraHTTPHeaders: { Authorization: `Bearer ${admin.accessToken}` },
|
||||||
|
})
|
||||||
|
const keeper = await sessionFor(adminCtx, 'Кладовщик', 'keep')
|
||||||
|
await adminCtx.dispose()
|
||||||
|
;(page as any)._sess = keeper
|
||||||
|
|
||||||
|
// Кладовщику закрыты деньги и настройки/сотрудники.
|
||||||
|
for (const path of ['/money/accounts', '/money/transactions', '/money/report',
|
||||||
|
'/settings/employees', '/settings/organization']) await expectNoAccess(page, path)
|
||||||
|
|
||||||
|
// Склад/приёмки/списания кладовщику доступны.
|
||||||
|
await attachSession(page, keeper, '/inventory/stock')
|
||||||
|
await expect(page.getByRole('heading', { name: 'Нет доступа' })).toHaveCount(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue