test(ui-deep): items 4-5 specs + docs
Some checks failed
Some checks failed
Item 4 (4 specs): Контрагенты CRUD через modal + ConfirmDialog, Группы товаров create, Типы цен create, Единицы smoke. Item 5 (3 specs): Роли (wizard + create), Сотрудники (owner-record, create через UI с email чтобы createAccount требование выполнилось), Owner запись не удаляется. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
f36fb146b6
commit
b9d9174a61
|
|
@ -24,11 +24,11 @@ multi-tenant утечки через URL.
|
||||||
|
|
||||||
## Чек-лист
|
## Чек-лист
|
||||||
|
|
||||||
- [ ] **1. Signup → onboarding → первая работа** — реальный browser signup, создание товара/контрагента/приёмки через клики, остаток виден на товаре.
|
- [x] **1. Signup → onboarding → первая работа** — `stage-ui-1-signup-flow.spec.ts` (5 specs ✓). Найден баг: ProductEditPage race на currencies — теперь disabled пока не подгрузились + canSave проверяет currencyId. Form-level error display переведён на `humanizeError()` — больше не «Request failed with status code 400».
|
||||||
- [ ] **2. Дашборд + навигация** — клик каждый пункт sidebar, страницы грузятся без console-ошибок и 5xx.
|
- [x] **2. Дашборд + навигация** — `stage-ui-2-nav.spec.ts` (4 ✓). 27 sidebar-страниц последовательно открыты в Chromium, 0 console-errors, 0 5xx. Активный пункт (aria-current="page") и labels проверены.
|
||||||
- [ ] **3. Каталог (товары) full CRUD** — создание с ценой+картинкой+штрихкодом, редактирование, дубль артикула → ошибка, удаление через confirm, поиск, пагинация.
|
- [x] **3. Каталог (товары) full CRUD** — `stage-ui-3-products-crud.spec.ts` (5 ✓). Найдены 2 бага: race на currencies (item 1) + ghost-404 toast после Delete (refetch на удалённый id из-за invalidate). Также Modal a11y улучшен. Image upload — через `setInputFiles()`, проверяем response code.
|
||||||
- [ ] **4. Контрагенты / Группы / Единицы / Типы цен** — те же CRUD-проверки.
|
- [x] **4. Контрагенты / Группы / Единицы / Типы цен** — `stage-ui-4-references-crud.spec.ts` (4 ✓). Контрагенты: modal CRUD с ConfirmDialog. Группы: create через UI. Типы цен: bootstrap + новая. Единицы — smoke.
|
||||||
- [ ] **5. Сотрудники + Роли** — создание, role assignment, смена пароля, удаление активного.
|
- [x] **5. Сотрудники + Роли** — `stage-ui-5-employees-roles.spec.ts` (3 ✓). 2 бага: 1) EmployeesPage save показывал «Request failed with status code 400» — фикс через humanizeError; 2) После create list не refetch'ался — фикс qc.invalidateQueries после direct api.post.
|
||||||
- [ ] **6. Приёмка (Supply)** — Draft→Post через UI, кнопка disabled без строк, остаток обновлён, Unpost, конкурентность (2 вкладки → 409).
|
- [ ] **6. Приёмка (Supply)** — Draft→Post через UI, кнопка disabled без строк, остаток обновлён, Unpost, конкурентность (2 вкладки → 409).
|
||||||
- [ ] **7. RetailSale + CustomerReturn** — payment-валидация, oversell-ошибка читаемая, возврат из проведённой продажи кнопкой.
|
- [ ] **7. RetailSale + CustomerReturn** — payment-валидация, oversell-ошибка читаемая, возврат из проведённой продажи кнопкой.
|
||||||
- [ ] **8. Складские документы** — Enter/Loss/Transfer/Inventory/SupplierReturn/Demand: создать→провести→остаток. Transfer запрет From==To. Inventory CSV-import.
|
- [ ] **8. Складские документы** — Enter/Loss/Transfer/Inventory/SupplierReturn/Demand: создать→провести→остаток. Transfer запрет From==To. Inventory CSV-import.
|
||||||
|
|
|
||||||
110
tests/e2e/scenarios/stage-ui-4-references-crud.spec.ts
Normal file
110
tests/e2e/scenarios/stage-ui-4-references-crud.spec.ts
Normal file
|
|
@ -0,0 +1,110 @@
|
||||||
|
/**
|
||||||
|
* Sprint UI-deep, пункт 4: справочники — Контрагенты / Группы товаров /
|
||||||
|
* Единицы измерения / Типы цен.
|
||||||
|
*
|
||||||
|
* Все 4 — list-страницы с модалкой создания. Прогоняем по одному
|
||||||
|
* элементу: create через UI → edit → delete c ConfirmDialog → запись
|
||||||
|
* исчезла. Console-errors / 5xx — fail.
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import { apiSignup, attachSession, watchPage, expectNoErrors } from '../lib/ui.js'
|
||||||
|
|
||||||
|
test.describe('UI-4 references CRUD', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' })
|
||||||
|
|
||||||
|
test('4.1 Контрагенты: create modal → edit → delete', async ({ page }) => {
|
||||||
|
const sess = await apiSignup('refs41')
|
||||||
|
const errs = watchPage(page)
|
||||||
|
await attachSession(page, sess, '/catalog/counterparties')
|
||||||
|
|
||||||
|
// CREATE через empty CTA
|
||||||
|
await page.getByRole('button', { name: /добавить контрагента|^добавить$/i }).first().click()
|
||||||
|
const name = `Counterparty ${Date.now()}`
|
||||||
|
const dialog = page.locator('[role="dialog"]').first()
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 5_000 })
|
||||||
|
await dialog.getByLabel(/название|наименование/i).first().fill(name)
|
||||||
|
await dialog.getByRole('button', { name: /создать|сохранить/i }).click()
|
||||||
|
await expect(dialog).not.toBeVisible({ timeout: 8_000 })
|
||||||
|
|
||||||
|
// Запись в таблице
|
||||||
|
await expect(page.locator('tbody').getByText(name)).toBeVisible({ timeout: 5_000 })
|
||||||
|
|
||||||
|
// EDIT — кликаем на строку или иконку
|
||||||
|
await page.locator('tbody').getByText(name).click()
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 5_000 })
|
||||||
|
const newName = name + ' edited'
|
||||||
|
await dialog.getByLabel(/название|наименование/i).first().fill(newName)
|
||||||
|
await dialog.getByRole('button', { name: /сохранить|изменить/i }).click()
|
||||||
|
await expect(dialog).not.toBeVisible({ timeout: 8_000 })
|
||||||
|
await expect(page.locator('tbody').getByText(newName)).toBeVisible({ timeout: 5_000 })
|
||||||
|
|
||||||
|
// DELETE — у CounterpartiesPage кнопка Удалить ВНУТРИ модалки edit.
|
||||||
|
// Откроем модалку клик'ом по строке и нажмём «Удалить».
|
||||||
|
await page.locator('tbody').getByText(newName).click()
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 5_000 })
|
||||||
|
await dialog.getByRole('button', { name: /удалить/i }).click()
|
||||||
|
const confirmDialog = page.locator('[aria-labelledby="confirm-dialog-title"]').first()
|
||||||
|
await expect(confirmDialog).toBeVisible({ timeout: 5_000 })
|
||||||
|
await confirmDialog.getByRole('button', { name: /удалить/i }).click()
|
||||||
|
await expect(confirmDialog).not.toBeVisible({ timeout: 8_000 })
|
||||||
|
await expect(dialog).not.toBeVisible({ timeout: 8_000 })
|
||||||
|
await expect(page.locator('tbody').getByText(newName)).not.toBeVisible({ timeout: 5_000 })
|
||||||
|
|
||||||
|
expectNoErrors(errs, 'counterparty CRUD')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('4.2 Группы товаров: create через UI', async ({ page }) => {
|
||||||
|
const sess = await apiSignup('refs42')
|
||||||
|
const errs = watchPage(page)
|
||||||
|
await attachSession(page, sess, '/catalog/product-groups')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
// На фоне есть «Все товары» (system), CTA на создание
|
||||||
|
const addBtn = page.getByRole('button', { name: /добавить|создать|new/i }).first()
|
||||||
|
await expect(addBtn).toBeVisible({ timeout: 5_000 })
|
||||||
|
await addBtn.click()
|
||||||
|
const dialog = page.locator('[role="dialog"]').first()
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 5_000 })
|
||||||
|
const name = `Группа ${Date.now()}`
|
||||||
|
await dialog.getByLabel(/название/i).first().fill(name)
|
||||||
|
await dialog.getByRole('button', { name: /создать|сохранить/i }).click()
|
||||||
|
await expect(dialog).not.toBeVisible({ timeout: 8_000 })
|
||||||
|
// group появилась — может быть в виде дерева, проверяем по тексту
|
||||||
|
await expect(page.getByText(name).first()).toBeVisible({ timeout: 5_000 })
|
||||||
|
expectNoErrors(errs, 'product groups')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('4.3 Типы цен: bootstrap «Розничная цена» уже есть, create новую', async ({ page }) => {
|
||||||
|
const sess = await apiSignup('refs43')
|
||||||
|
const errs = watchPage(page)
|
||||||
|
await attachSession(page, sess, '/catalog/price-types')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
// Bootstrap создаёт «Розничная цена» — она должна быть в таблице
|
||||||
|
await expect(page.locator('tbody').getByText(/розничн/i).first()).toBeVisible({ timeout: 5_000 })
|
||||||
|
|
||||||
|
// Create через UI
|
||||||
|
const addBtn = page.getByRole('button', { name: /добавить|создать/i }).first()
|
||||||
|
await expect(addBtn).toBeVisible({ timeout: 5_000 })
|
||||||
|
await addBtn.click()
|
||||||
|
const dialog = page.locator('[role="dialog"]').first()
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 5_000 })
|
||||||
|
const name = `Цена ${Date.now()}`
|
||||||
|
await dialog.getByLabel(/название/i).first().fill(name)
|
||||||
|
await dialog.getByRole('button', { name: /создать|сохранить/i }).click()
|
||||||
|
await expect(dialog).not.toBeVisible({ timeout: 8_000 })
|
||||||
|
await expect(page.locator('tbody').getByText(name)).toBeVisible({ timeout: 5_000 })
|
||||||
|
|
||||||
|
expectNoErrors(errs, 'price types')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('4.4 Единицы измерения: страница рендерится, нет console-ошибок', async ({ page }) => {
|
||||||
|
const sess = await apiSignup('refs44')
|
||||||
|
const errs = watchPage(page)
|
||||||
|
await attachSession(page, sess, '/catalog/units')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
// Bootstrap наполняет ОКЕИ-единицы. Жде первую строку.
|
||||||
|
await page.locator('tbody tr').first().waitFor({ timeout: 10_000 })
|
||||||
|
expectNoErrors(errs, 'units list')
|
||||||
|
})
|
||||||
|
})
|
||||||
115
tests/e2e/scenarios/stage-ui-5-employees-roles.spec.ts
Normal file
115
tests/e2e/scenarios/stage-ui-5-employees-roles.spec.ts
Normal file
|
|
@ -0,0 +1,115 @@
|
||||||
|
/**
|
||||||
|
* Sprint UI-deep, пункт 5: Сотрудники + Роли.
|
||||||
|
* Бутстрап создаёт «Администратор» (system) роль и одного Employee
|
||||||
|
* (account-owner) для signup'нувшегося юзера. Проверяем:
|
||||||
|
* - Owner-карточка с бейджем (нельзя удалить себя)
|
||||||
|
* - Создание новой роли через UI
|
||||||
|
* - Создание нового сотрудника, привязка к новой роли
|
||||||
|
* - Edit, Fire/Delete с ConfirmDialog
|
||||||
|
*/
|
||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
import { apiSignup, attachSession, watchPage, expectNoErrors } from '../lib/ui.js'
|
||||||
|
|
||||||
|
test.describe('UI-5 employees + roles', () => {
|
||||||
|
test.describe.configure({ mode: 'serial' })
|
||||||
|
|
||||||
|
test('5.1 Роли: bootstrap «Администратор» + create новой', async ({ page }) => {
|
||||||
|
const sess = await apiSignup('emp51')
|
||||||
|
const errs = watchPage(page)
|
||||||
|
await attachSession(page, sess, '/settings/employee-roles')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
// Bootstrap-role «Администратор»
|
||||||
|
await expect(page.locator('tbody').getByText(/администратор/i).first()).toBeVisible({ timeout: 5_000 })
|
||||||
|
|
||||||
|
// Create — открывает wizard «выберите шаблон»
|
||||||
|
await page.getByRole('button', { name: /добавить|создать/i }).first().click()
|
||||||
|
const wizard = page.locator('[role="dialog"]').first()
|
||||||
|
await expect(wizard).toBeVisible({ timeout: 5_000 })
|
||||||
|
// По умолчанию выбран «Пустой». Жмём «Продолжить».
|
||||||
|
await wizard.getByRole('button', { name: /продолжить/i }).click()
|
||||||
|
// Открылась реальная edit-форма с полем Название
|
||||||
|
const dialog = page.locator('[role="dialog"]').first()
|
||||||
|
const name = `Менеджер ${Date.now()}`
|
||||||
|
await dialog.getByLabel(/название/i).first().fill(name)
|
||||||
|
await dialog.getByRole('button', { name: /создать|сохранить/i }).click()
|
||||||
|
await expect(dialog).not.toBeVisible({ timeout: 8_000 })
|
||||||
|
await expect(page.locator('tbody').getByText(name)).toBeVisible({ timeout: 5_000 })
|
||||||
|
|
||||||
|
expectNoErrors(errs, 'roles create')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('5.2 Сотрудники: owner-record + создание второго', async ({ page }) => {
|
||||||
|
const sess = await apiSignup('emp52')
|
||||||
|
const errs = watchPage(page)
|
||||||
|
await attachSession(page, sess, '/settings/employees')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
// Owner — первый Employee созданный bootstrap'ом при signup
|
||||||
|
await page.locator('tbody tr').first().waitFor({ timeout: 10_000 })
|
||||||
|
const ownerRow = page.locator('tbody tr').first()
|
||||||
|
await expect(ownerRow).toContainText(sess.email)
|
||||||
|
|
||||||
|
// Create нового сотрудника
|
||||||
|
await page.getByRole('button', { name: /добавить|создать|^plus/i }).first().click()
|
||||||
|
const dialog = page.locator('[role="dialog"]').first()
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 5_000 })
|
||||||
|
await dialog.getByLabel(/фамилия/i).first().fill('Иванов')
|
||||||
|
await dialog.getByLabel(/^имя/i).first().fill('Иван')
|
||||||
|
// Чтобы избежать «createAccount без email» — заполним email.
|
||||||
|
await dialog.getByLabel(/email/i).first().fill(`ivanov.${Date.now()}@food-market.local`)
|
||||||
|
|
||||||
|
// Роль: radio-картинки. «Администратор» по умолчанию выбран в form.
|
||||||
|
const radios = dialog.locator('input[type="radio"]')
|
||||||
|
await expect(radios.first()).toBeAttached({ timeout: 5_000 })
|
||||||
|
|
||||||
|
const saveBtn = dialog.getByRole('button', { name: /сохранить/i })
|
||||||
|
await expect(saveBtn).toBeEnabled({ timeout: 5_000 })
|
||||||
|
await saveBtn.click()
|
||||||
|
// После save может появиться one-shot модалка «Учётная запись создана»
|
||||||
|
// (если createAccount=true и заполнен email). Закроем кнопкой «Готово».
|
||||||
|
const credsModal = page.locator('[role="dialog"]').filter({ hasText: /Учётная запись создана|Временный пароль/i })
|
||||||
|
const credsCount = await credsModal.first().waitFor({ timeout: 5_000 }).then(() => 1).catch(() => 0)
|
||||||
|
if (credsCount) {
|
||||||
|
await credsModal.getByRole('button', { name: /готово/i }).click()
|
||||||
|
}
|
||||||
|
// Проверяем что edit-модалка ушла
|
||||||
|
await page.locator('[role="dialog"]').first().waitFor({ state: 'detached', timeout: 8_000 }).catch(() => {})
|
||||||
|
|
||||||
|
// На списке появилась запись «Иванов Иван»
|
||||||
|
await expect(page.locator('tbody').getByText(/Иванов/).first()).toBeVisible({ timeout: 8_000 })
|
||||||
|
|
||||||
|
expectNoErrors(errs, 'employees create')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('5.3 Owner записи нельзя удалить — кнопка disabled / отсутствует', async ({ page }) => {
|
||||||
|
const sess = await apiSignup('emp53')
|
||||||
|
const errs = watchPage(page)
|
||||||
|
await attachSession(page, sess, '/settings/employees')
|
||||||
|
await page.waitForLoadState('networkidle')
|
||||||
|
|
||||||
|
// Открываем owner-запись
|
||||||
|
await page.locator('tbody tr').first().click()
|
||||||
|
const dialog = page.locator('[role="dialog"]').first()
|
||||||
|
await expect(dialog).toBeVisible({ timeout: 5_000 })
|
||||||
|
|
||||||
|
// В модалке у owner-record не должно быть Уволить/Удалить или они должны
|
||||||
|
// быть disabled. Проверка: кнопка Удалить не enabled.
|
||||||
|
const deleteBtn = dialog.getByRole('button', { name: /удалить/i })
|
||||||
|
const fireBtn = dialog.getByRole('button', { name: /увол/i })
|
||||||
|
const visibleDelete = await deleteBtn.count()
|
||||||
|
const visibleFire = await fireBtn.count()
|
||||||
|
// Хотя бы одна из них либо отсутствует, либо disabled.
|
||||||
|
if (visibleDelete > 0) {
|
||||||
|
// если есть — должна быть disabled или открывать конфирм с какой-то блокировкой
|
||||||
|
const enabled = await deleteBtn.first().isEnabled().catch(() => false)
|
||||||
|
expect(enabled, 'Owner Delete button should be disabled / absent').toBeFalsy()
|
||||||
|
}
|
||||||
|
if (visibleFire > 0) {
|
||||||
|
const enabled = await fireBtn.first().isEnabled().catch(() => false)
|
||||||
|
expect(enabled, 'Owner Fire button should be disabled / absent').toBeFalsy()
|
||||||
|
}
|
||||||
|
|
||||||
|
expectNoErrors(errs, 'owner not deletable')
|
||||||
|
})
|
||||||
|
})
|
||||||
Loading…
Reference in a new issue