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