Compare commits

..

3 commits

Author SHA1 Message Date
nns a539c31cbc docs(uat): часть C отчёта (per-control в браузере) + прогресс done + nightly step 3d
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:11:33 +05:00
nns eafc8c15fc test(uat): расширить per-control на оприходования/списания/инвентаризации/возвраты/движения
21/21 ✓: Товары (поиск/сброс/сорт/пагинация/экспорт), Приёмки (поиск/фильтр оплаты/
сорт/экспорт + жизненный цикл провести→снять→удалить), Продажи (поиск/сорт),
Движения (store-фильтр), Контрагенты (поиск), Оприходования/Списания/Инвентаризации/
Возвраты поставщику (поиск). Семантику параметр→строки держит filters-sweep.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 12:10:18 +05:00
nns 605c1bac2c 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>
2026-10-09 12:06:02 +05:00
4 changed files with 418 additions and 6 deletions

View 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.

View file

@ -71,9 +71,38 @@ keeper 17, cashier 8 — reload каждого пункта без 4xx/5xx/toast
- Устаревший oversell-тест (409) приведён к политике «минус разрешён».
- `food-market.UnitTests` не компилировался (FakeServer) — починен, юнит-тесты снова идут.
## Остаётся на следующие итерации (честно, не покрыто этой сессией)
- Ручной обход КАЖДОГО контрола на КАЖДОМ экране P2–P4 в браузере (периоды/статусы/
сорт на продажах/приёмках/движениях/контрагентах/сотрудниках). Частично покрыто
filters-sweep (списки) и menu-walk (достижимость); полный per-control UI-UAT — далее.
- UI-гейтинг прав для роли «кассир» в вебе (API-права покрыты /api/me; UI-скрытие — далее).
- Реальное окно POS на Windows 11 с новым лого (VM-захват) — отдельная задача [[pos_windows_capture]].
## Часть 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`).

View 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([])
})
})

View 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)
})
})