Compare commits
No commits in common. "a539c31cbc5601f3562e35a97b522633867ecd24" and "12406d2503191f0b646b9b8c4a15f74b6a0eed70" have entirely different histories.
a539c31cbc
...
12406d2503
|
|
@ -1,66 +0,0 @@
|
|||
# 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,38 +71,9 @@ keeper 17, cashier 8 — reload каждого пункта без 4xx/5xx/toast
|
|||
- Устаревший oversell-тест (409) приведён к политике «минус разрешён».
|
||||
- `food-market.UnitTests` не компилировался (FakeServer) — починен, юнит-тесты снова идут.
|
||||
|
||||
## Часть C. Per-control UI-UAT в браузере (доделано 2026-10-09)
|
||||
Методика: каждый контрол в РЕАЛЬНОМ браузере (Playwright, стенд) должен слать на API
|
||||
правильный параметр (поиск→search, сортировка→sort, фильтр→свой параметр, пагинация→page=2,
|
||||
экспорт→/export&format); семантику «параметр→правильные строки» независимо держит
|
||||
filters-sweep (21/21). Жизненный цикл документа — настоящими кнопками UI. Данные — свежая
|
||||
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`).
|
||||
## Остаётся на следующие итерации (честно, не покрыто этой сессией)
|
||||
- Ручной обход КАЖДОГО контрола на КАЖДОМ экране P2–P4 в браузере (периоды/статусы/
|
||||
сорт на продажах/приёмках/движениях/контрагентах/сотрудниках). Частично покрыто
|
||||
filters-sweep (списки) и menu-walk (достижимость); полный per-control UI-UAT — далее.
|
||||
- UI-гейтинг прав для роли «кассир» в вебе (API-права покрыты /api/me; UI-скрытие — далее).
|
||||
- Реальное окно POS на Windows 11 с новым лого (VM-захват) — отдельная задача [[pos_windows_capture]].
|
||||
|
|
|
|||
|
|
@ -1,216 +0,0 @@
|
|||
/**
|
||||
* 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([])
|
||||
})
|
||||
})
|
||||
|
|
@ -1,101 +0,0 @@
|
|||
/**
|
||||
* 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