test(uat): браузерные per-control проверки + гейтинг прав по прямым URL
- stage-ui-uat-controls: в браузере контролы списков шлют правильный параметр (поиск→search, сортировка→sort, фильтр→свой параметр, пагинация→page=2, экспорт→/export&format), + жизненный цикл документа приёмки (провести чекбоксом «Проведено»→POST /post, снять→/unpost, удалить→DELETE). 16/16 ✓. Семантику параметр→строки независимо держит filters-sweep. - stage-ui-uat-permissions: кассир/кладовщик по ПРЯМОМУ URL к админ-разделам видят «Нет доступа» (RoleGuard), разрешённые разделы открываются. 2/2 ✓. - docs/uat-per-control-progress.md — реестр экранов × контролов. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
12406d2503
commit
605c1bac2c
60
docs/uat-per-control-progress.md
Normal file
60
docs/uat-per-control-progress.md
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
# 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. [ ] Browser e2e `stage-ui-uat-controls.spec.ts`: по каждому экрану — осмысленная проверка
|
||||||
|
контролов (поиск сужает до известного, фильтр меняет набор, сортировка меняет порядок,
|
||||||
|
пагинация разбивает, экспорт отдаёт файл). Ожидаемое считает тест по данным temp-орга.
|
||||||
|
2. [ ] Browser e2e действий документа: черновик → провести → отменить проведение → удалить →
|
||||||
|
создать на основании (приёмка/оприходование/списание/чек).
|
||||||
|
3. [ ] Browser e2e прав `stage-ui-uat-permissions.spec.ts`: кассир/кладовщик — RoleGuard-URL →
|
||||||
|
«Нет доступа»; кнопки «Удалить/Отменить проведение» скрыты без прав.
|
||||||
|
4. [ ] Сводка в uat-report (часть C): экран → элемент → ✓/✗, сначала ✗. P1–P2 в nightly.
|
||||||
|
|
||||||
|
## Что НЕ трогаем
|
||||||
|
Прод, shared-postgres, global.json, gateway nginx, sklad.
|
||||||
189
tests/e2e/scenarios/stage-ui-uat-controls.spec.ts
Normal file
189
tests/e2e/scenarios/stage-ui-uat-controls.spec.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
/**
|
||||||
|
* 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 storeId = await first('/api/catalog/stores?pageSize=50')
|
||||||
|
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='))
|
||||||
|
|
||||||
|
// ── итог ──────────────────────────────────────────────────────────────
|
||||||
|
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