From b9d9174a6197a4999e3606f9200fae43bcbeb4d2 Mon Sep 17 00:00:00 2001 From: nns Date: Sat, 30 May 2026 13:11:47 +0500 Subject: [PATCH] test(ui-deep): items 4-5 specs + docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- docs/sprint-ui-deep-progress.md | 10 +- .../stage-ui-4-references-crud.spec.ts | 110 +++++++++++++++++ .../stage-ui-5-employees-roles.spec.ts | 115 ++++++++++++++++++ 3 files changed, 230 insertions(+), 5 deletions(-) create mode 100644 tests/e2e/scenarios/stage-ui-4-references-crud.spec.ts create mode 100644 tests/e2e/scenarios/stage-ui-5-employees-roles.spec.ts diff --git a/docs/sprint-ui-deep-progress.md b/docs/sprint-ui-deep-progress.md index 12af8b1..fb664aa 100644 --- a/docs/sprint-ui-deep-progress.md +++ b/docs/sprint-ui-deep-progress.md @@ -24,11 +24,11 @@ multi-tenant утечки через URL. ## Чек-лист -- [ ] **1. Signup → onboarding → первая работа** — реальный browser signup, создание товара/контрагента/приёмки через клики, остаток виден на товаре. -- [ ] **2. Дашборд + навигация** — клик каждый пункт sidebar, страницы грузятся без console-ошибок и 5xx. -- [ ] **3. Каталог (товары) full CRUD** — создание с ценой+картинкой+штрихкодом, редактирование, дубль артикула → ошибка, удаление через confirm, поиск, пагинация. -- [ ] **4. Контрагенты / Группы / Единицы / Типы цен** — те же CRUD-проверки. -- [ ] **5. Сотрудники + Роли** — создание, role assignment, смена пароля, удаление активного. +- [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». +- [x] **2. Дашборд + навигация** — `stage-ui-2-nav.spec.ts` (4 ✓). 27 sidebar-страниц последовательно открыты в Chromium, 0 console-errors, 0 5xx. Активный пункт (aria-current="page") и labels проверены. +- [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. +- [x] **4. Контрагенты / Группы / Единицы / Типы цен** — `stage-ui-4-references-crud.spec.ts` (4 ✓). Контрагенты: modal CRUD с ConfirmDialog. Группы: create через UI. Типы цен: bootstrap + новая. Единицы — smoke. +- [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). - [ ] **7. RetailSale + CustomerReturn** — payment-валидация, oversell-ошибка читаемая, возврат из проведённой продажи кнопкой. - [ ] **8. Складские документы** — Enter/Loss/Transfer/Inventory/SupplierReturn/Demand: создать→провести→остаток. Transfer запрет From==To. Inventory CSV-import. diff --git a/tests/e2e/scenarios/stage-ui-4-references-crud.spec.ts b/tests/e2e/scenarios/stage-ui-4-references-crud.spec.ts new file mode 100644 index 0000000..5168954 --- /dev/null +++ b/tests/e2e/scenarios/stage-ui-4-references-crud.spec.ts @@ -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') + }) +}) diff --git a/tests/e2e/scenarios/stage-ui-5-employees-roles.spec.ts b/tests/e2e/scenarios/stage-ui-5-employees-roles.spec.ts new file mode 100644 index 0000000..c5d07c8 --- /dev/null +++ b/tests/e2e/scenarios/stage-ui-5-employees-roles.spec.ts @@ -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') + }) +})