diff --git a/tests/e2e/lib/ui.ts b/tests/e2e/lib/ui.ts
index f2176ff..841d8b7 100644
--- a/tests/e2e/lib/ui.ts
+++ b/tests/e2e/lib/ui.ts
@@ -87,6 +87,10 @@ export function watchPage(page: Page, opts?: {
page.on('console', (msg: ConsoleMessage) => {
if (msg.type() !== 'error') return
const t = msg.text()
+ // «Failed to load resource: the server responded with a status of XXX» —
+ // авто-сообщение Chromium на каждый 4xx/5xx, дублирует network-обработчик.
+ // Не считаем это самостоятельной ошибкой.
+ if (/^Failed to load resource: the server responded with a status of \d+/i.test(t)) return
if ((opts?.expectedConsoleContains ?? []).some(s => t.includes(s))) return
acc.console.push(t)
})
diff --git a/tests/e2e/scenarios/stage-ui-2-nav.spec.ts b/tests/e2e/scenarios/stage-ui-2-nav.spec.ts
new file mode 100644
index 0000000..6fb2d70
--- /dev/null
+++ b/tests/e2e/scenarios/stage-ui-2-nav.spec.ts
@@ -0,0 +1,118 @@
+/**
+ * Sprint UI-deep, пункт 2: дашборд + навигация.
+ * Прокликиваем каждый sidebar-пункт под Owner-Admin'ом, проверяем что
+ * страница рендерится без console-errors и без 5xx/4xx ответов.
+ */
+import { test, expect } from '@playwright/test'
+import { apiSignup, attachSession, watchPage, expectNoErrors } from '../lib/ui.js'
+
+const NAV_PATHS = [
+ { path: '/', heading: /быстрый старт|онбординг|главная|начать/i },
+ { path: '/dashboard', heading: /выручка|обзор|аналитика|главная/i },
+ { path: '/catalog/products', heading: /товары/i },
+ { path: '/catalog/product-groups', heading: /группы/i },
+ { path: '/catalog/units', heading: /ед\.|единиц/i },
+ { path: '/catalog/price-types', heading: /типы цен/i },
+ { path: '/catalog/counterparties', heading: /контрагент/i },
+ { path: '/inventory/stock', heading: /остатк/i },
+ { path: '/inventory/movements', heading: /движени/i },
+ { path: '/inventory/enters', heading: /оприходован/i },
+ { path: '/inventory/losses', heading: /списан/i },
+ { path: '/inventory/transfers', heading: /перемещен/i },
+ { path: '/inventory/inventories', heading: /инвентар/i },
+ { path: '/purchases/supplies', heading: /приёмк|приемк/i },
+ { path: '/purchases/supplier-returns', heading: /возврат/i },
+ { path: '/sales/retail', heading: /рознич|чек/i },
+ { path: '/sales/demands', heading: /оптов|отгрузк/i },
+ { path: '/reports/sales', heading: /продаж/i },
+ { path: '/reports/stock', heading: /остатк/i },
+ { path: '/reports/profit', heading: /прибыл/i },
+ { path: '/reports/abc', heading: /abc|анализ/i },
+ { path: '/audit-log', heading: /журнал/i },
+ { path: '/settings/organization', heading: /настройк/i },
+ { path: '/catalog/stores', heading: /склад/i },
+ { path: '/catalog/retail-points', heading: /касс/i },
+ { path: '/settings/employees', heading: /сотрудник/i },
+ { path: '/settings/employee-roles', heading: /рол/i },
+] as const
+
+test.describe('UI-2 dashboard + navigation', () => {
+ test.describe.configure({ mode: 'serial' })
+
+ test('2.1 каждая страница sidebar открывается без console-errors / 5xx', async ({ page }) => {
+ test.setTimeout(180_000) // 27 страниц по ~5с = 135с
+ const sess = await apiSignup('nav')
+ const errs = watchPage(page, {
+ // Stock/Reports на свежей орге могут отдавать 404 на отчёт если нет данных,
+ // но это API уровень и сейчас не должно быть. Если запись «404 /api/...»
+ // появится — это баг и тест упадёт.
+ })
+ await attachSession(page, sess, '/dashboard')
+ await expect(page).toHaveURL(/\/dashboard/, { timeout: 10_000 })
+
+ for (const { path } of NAV_PATHS) {
+ await page.goto(path, { waitUntil: 'domcontentloaded' })
+ // ждём первого heading на странице — это значит React-роут отработал
+ await page.waitForLoadState('networkidle', { timeout: 20_000 })
+ //
непустой
+ const bodyText = await page.locator('body').innerText()
+ expect(bodyText.length, `body не пустой на ${path}`).toBeGreaterThan(100)
+ }
+
+ expectNoErrors(errs, 'sidebar walk')
+ })
+
+ test('2.2 sidebar содержит все ожидаемые пункты', async ({ page }) => {
+ const sess = await apiSignup('nav2')
+ const errs = watchPage(page)
+ await attachSession(page, sess, '/dashboard')
+
+ const expectedLabels = [
+ 'Главная', 'Аналитика', 'Товары', 'Группы', 'Ед. измерения', 'Типы цен',
+ 'Контрагенты', 'Остатки', 'Движения', 'Оприходования', 'Списания',
+ 'Перемещения', 'Инвентаризации', 'Приёмки', 'Возвраты поставщикам',
+ 'Розничные чеки', 'Оптовые отгрузки', 'Продажи', 'Прибыль', 'ABC-анализ',
+ 'Журнал изменений', 'Общие', 'Склады', 'Кассы', 'Сотрудники', 'Роли',
+ ]
+ for (const lbl of expectedLabels) {
+ // в sidebar nav (aside)
+ await expect(
+ page.locator('aside').getByRole('link', { name: new RegExp(`^${lbl}$`) }).first(),
+ `пункт «${lbl}»`,
+ ).toBeVisible({ timeout: 5_000 })
+ }
+ expectNoErrors(errs, 'sidebar labels')
+ })
+
+ test('2.3 клик по пункту меняет URL и активный стейт', async ({ page }) => {
+ const sess = await apiSignup('nav3')
+ const errs = watchPage(page)
+ await attachSession(page, sess, '/dashboard')
+
+ // Клик на «Товары»
+ await page.locator('aside').getByRole('link', { name: /^Товары$/ }).first().click()
+ await page.waitForURL(/\/catalog\/products/, { timeout: 10_000 })
+
+ // Активный (текущий) пункт в sidebar — NavLink из react-router-dom добавляет
+ // active class. Проверим через aria-current="page".
+ const active = page.locator('aside a[aria-current="page"]')
+ await expect(active).toHaveText(/Товары/, { timeout: 5_000 })
+
+ expectNoErrors(errs, 'nav click')
+ })
+
+ test('2.4 несуществующий tenant-роут → редирект на онбординг / 404 без белого экрана', async ({ page }) => {
+ const sess = await apiSignup('nav4')
+ const errs = watchPage(page, {
+ // 404 ожидаем — это и есть проверка
+ expected4xxContains: ['/api/'],
+ })
+ await attachSession(page, sess, '/garbage-path-' + Date.now())
+ await page.waitForLoadState('networkidle', { timeout: 10_000 })
+ // body не должен быть пустым
+ const bodyText = await page.locator('body').innerText()
+ expect(bodyText.length).toBeGreaterThan(100)
+ // sidebar всё ещё виден (приложение не упало)
+ await expect(page.locator('aside').first()).toBeVisible()
+ })
+})
diff --git a/tests/e2e/scenarios/stage-ui-3-products-crud.spec.ts b/tests/e2e/scenarios/stage-ui-3-products-crud.spec.ts
new file mode 100644
index 0000000..d553750
--- /dev/null
+++ b/tests/e2e/scenarios/stage-ui-3-products-crud.spec.ts
@@ -0,0 +1,254 @@
+/**
+ * Sprint UI-deep, пункт 3: Products full CRUD через UI.
+ * - Создание с разными типами цен и штрихкодом
+ * - Загрузка изображения через page.setInputFiles()
+ * - Редактирование
+ * - Дубль артикула → ошибка с понятным сообщением
+ * - Удаление через ConfirmDialog → подтвердить → запись пропала
+ * - Поиск по списку
+ * - Пагинация (создаём >50 товаров и проверяем)
+ */
+import { test, expect, request as apiRequest } from '@playwright/test'
+import { apiSignup, attachSession, watchPage, expectNoErrors } from '../lib/ui.js'
+import { promises as fs } from 'node:fs'
+import path from 'node:path'
+
+const BASE = process.env.E2E_ADMIN_URL ?? 'https://test.admin.food-market.kz'
+
+test.describe('UI-3 products full CRUD', () => {
+ test.describe.configure({ mode: 'serial' })
+
+ test('3.1 create → edit → delete через UI с confirm-диалогом', async ({ page }) => {
+ test.setTimeout(120_000)
+ const sess = await apiSignup('crud31')
+ const errs = watchPage(page)
+ await attachSession(page, sess, '/catalog/products')
+
+ // CREATE
+ await page.getByRole('button', { name: /создать первый товар/i }).click()
+ await page.waitForURL(/\/catalog\/products\/new/, { timeout: 10_000 })
+ const name = `CRUD ${Date.now()}`
+ await page.getByLabel('Название *').fill(name)
+ await page.getByLabel(/Розничная/).first().fill('1500')
+ // Дожидаемся currencies (MoneyInput должен стать enabled)
+ await expect(page.getByLabel(/Розничная/).first()).toBeEnabled()
+ const saveBtn = page.getByRole('button', { name: /^сохранить$/i }).last()
+ await expect(saveBtn).toBeEnabled({ timeout: 8_000 })
+ await saveBtn.click()
+ // После save переходим на /catalog/products/
+ await page.waitForURL(/\/catalog\/products\/[0-9a-f-]{36}$/, { timeout: 10_000 })
+
+ // EDIT — меняем цену
+ await page.getByLabel(/Розничная/).first().fill('2000')
+ await saveBtn.click()
+ // PUT → navigate('/catalog/products') (на список). Toast «Сохранено» может
+ // мелькнуть/исчезнуть до waitForURL, поэтому не настаиваем на нём.
+ await page.waitForURL(/\/catalog\/products$/, { timeout: 10_000 })
+ // На списке наш товар с обновлённой ценой «2 000»
+ await expect(page.locator('tbody').getByText(/2[\s ]?000/).first())
+ .toBeVisible({ timeout: 5_000 })
+
+ // Возвращаемся в карточку для DELETE
+ await page.locator('tbody').getByText(name).click()
+ await page.waitForURL(/\/catalog\/products\/[0-9a-f-]{36}$/, { timeout: 10_000 })
+
+ // DELETE — кнопка Удалить → ConfirmDialog → подтвердить
+ await page.getByRole('button', { name: /удалить/i }).click()
+ const dialog = page.locator('[aria-labelledby="confirm-dialog-title"]').first()
+ await expect(dialog).toBeVisible({ timeout: 5_000 })
+ await expect(dialog).toContainText(/удалить товар/i)
+ await dialog.getByRole('button', { name: /удалить/i }).click()
+ // Редирект на список
+ await page.waitForURL(/\/catalog\/products$/, { timeout: 10_000 })
+ // Запись исчезла
+ await expect(page.locator('tbody').getByText(name)).not.toBeVisible({ timeout: 5_000 })
+
+ expectNoErrors(errs, 'crud product')
+ })
+
+ test('3.2 дубль артикула → 409 с понятной ошибкой через toast', async ({ page }) => {
+ const sess = await apiSignup('crud32')
+ const errs = watchPage(page, {
+ // ожидаем 409 на дубликат — это и есть проверка
+ expected4xxContains: ['/api/catalog/products'],
+ })
+ // Создаём первый товар через API
+ const ctx = await apiRequest.newContext({
+ baseURL: BASE, ignoreHTTPSErrors: true,
+ extraHTTPHeaders: { Authorization: `Bearer ${sess.accessToken}` },
+ })
+ type Paged = { items: T[] }
+ const units = await (await ctx.get('/api/catalog/units-of-measure?pageSize=200')).json() as Paged<{ id: string; code: string }>
+ const groups = await (await ctx.get('/api/catalog/product-groups')).json() as Paged<{ id: string }>
+ const pts = await (await ctx.get('/api/catalog/price-types')).json() as Paged<{ id: string; isRetail: boolean }>
+ const curs = await (await ctx.get('/api/catalog/currencies')).json() as Paged<{ id: string; code: string }>
+ const unit = units.items.find(u => u.code === '796')!
+ const grp = groups.items[0]
+ const retail = pts.items.find(p => p.isRetail)!
+ const kzt = curs.items.find(c => c.code === 'KZT')!
+
+ const article = `DUP-${Date.now()}`
+ const first = await ctx.post('/api/catalog/products', {
+ data: {
+ name: 'Первый', article,
+ unitOfMeasureId: unit.id, vat: 12, vatEnabled: true,
+ productGroupId: grp.id, packaging: 1,
+ prices: [{ priceTypeId: retail.id, amount: 100, currencyId: kzt.id }],
+ barcodes: [{ code: '4000000000016', type: 1, isPrimary: true }],
+ },
+ })
+ expect([200, 201]).toContain(first.status())
+ await ctx.dispose()
+
+ // Через UI пытаемся создать второй с тем же артикулом
+ await attachSession(page, sess, '/catalog/products/new')
+ await page.getByLabel('Название *').fill('Дубль')
+ // Перебиваем сгенерированный артикул на дубликат
+ await page.getByLabel('Артикул *').fill(article)
+ await page.getByLabel(/Розничная/).first().fill('200')
+ await expect(page.getByLabel(/Розничная/).first()).toBeEnabled()
+ const saveBtn = page.getByRole('button', { name: /^сохранить$/i }).last()
+ await expect(saveBtn).toBeEnabled({ timeout: 8_000 })
+ await saveBtn.click()
+
+ // Должен появиться toast с человеко-читаемым сообщением (не «Request failed»)
+ const alertText = await page.getByRole('alert').first().textContent({ timeout: 8_000 })
+ expect(alertText, 'toast не должен содержать generic axios message')
+ .not.toMatch(/Request failed with status code/i)
+ // должен содержать ключевые слова про артикул/уникальность
+ expect(alertText?.toLowerCase() ?? '').toMatch(/артикул|уже существует|already|уник/i)
+
+ expectNoErrors(errs, 'dup article')
+ })
+
+ test('3.3 поиск по списку', async ({ page }) => {
+ const sess = await apiSignup('crud33')
+ const errs = watchPage(page)
+ // Seed-demo для наполнения
+ const ctx = await apiRequest.newContext({
+ baseURL: BASE, ignoreHTTPSErrors: true,
+ extraHTTPHeaders: { Authorization: `Bearer ${sess.accessToken}` },
+ })
+ await ctx.post('/api/admin/seed-demo', { data: {} })
+ await ctx.dispose()
+
+ await attachSession(page, sess, '/catalog/products')
+ // Жде первую строку
+ await page.locator('tbody tr').first().waitFor({ timeout: 10_000 })
+
+ // Поиск «Молоко»
+ await page.getByPlaceholder(/поиск/i).fill('Молоко')
+ // Дебаунс ~300ms + сетевой запрос
+ await page.waitForLoadState('networkidle')
+ // В таблице должны остаться только Молоко-records
+ const rows = await page.locator('tbody tr').count()
+ expect(rows).toBeGreaterThan(0)
+ const allCellsText = await page.locator('tbody').innerText()
+ expect(allCellsText.toLowerCase()).toContain('молок')
+ // Без других категорий
+ expect(allCellsText.toLowerCase()).not.toContain('хлеб ржан')
+
+ expectNoErrors(errs, 'search')
+ })
+
+ test('3.4 пагинация при >50 товаров', async ({ page }) => {
+ test.setTimeout(120_000)
+ const sess = await apiSignup('crud34')
+ const errs = watchPage(page)
+ // Seed-demo даёт 50; добавим ещё немного через API
+ const ctx = await apiRequest.newContext({
+ baseURL: BASE, ignoreHTTPSErrors: true,
+ extraHTTPHeaders: { Authorization: `Bearer ${sess.accessToken}` },
+ })
+ await ctx.post('/api/admin/seed-demo', { data: {} })
+ type Paged = { items: T[] }
+ const units = await (await ctx.get('/api/catalog/units-of-measure?pageSize=200')).json() as Paged<{ id: string; code: string }>
+ const groups = await (await ctx.get('/api/catalog/product-groups?pageSize=200')).json() as Paged<{ id: string }>
+ const pts = await (await ctx.get('/api/catalog/price-types?pageSize=50')).json() as Paged<{ id: string; isRetail: boolean }>
+ const curs = await (await ctx.get('/api/catalog/currencies')).json() as Paged<{ id: string; code: string }>
+ const unit = units.items.find(u => u.code === '796')!
+ const grp = groups.items[0]
+ const retail = pts.items.find(p => p.isRetail)!
+ const kzt = curs.items.find(c => c.code === 'KZT')!
+ // Дозабиваем до 55 (нужно >50 чтобы пагинация была)
+ for (let i = 0; i < 5; i++) {
+ const r = await ctx.post('/api/catalog/products', {
+ data: {
+ name: `Extra${i}`, article: `EXTRA-${Date.now()}-${i}`,
+ unitOfMeasureId: unit.id, vat: 12, vatEnabled: true,
+ productGroupId: grp.id, packaging: 1,
+ prices: [{ priceTypeId: retail.id, amount: 100 + i, currencyId: kzt.id }],
+ barcodes: [{ code: `40000000${(50000 + i).toString().padStart(5, '0')}`.slice(0, 13), type: 1, isPrimary: true }],
+ },
+ })
+ expect([200, 201]).toContain(r.status())
+ }
+ await ctx.dispose()
+
+ await attachSession(page, sess, '/catalog/products')
+ await page.locator('tbody tr').first().waitFor({ timeout: 10_000 })
+
+ // Pagination footer показывает «Стр X из Y» / номера страниц
+ // 55 / 50 = 2 страницы
+ await expect(page.locator('text=/2|следую|next/i').first()).toBeVisible({ timeout: 5_000 })
+ expectNoErrors(errs, 'pagination')
+ })
+
+ test('3.5 загрузка изображения в карточку товара', async ({ page }) => {
+ test.setTimeout(90_000)
+ const sess = await apiSignup('crud35')
+ const errs = watchPage(page)
+ // Сначала создаём товар через API чтобы открыть его edit
+ const ctx = await apiRequest.newContext({
+ baseURL: BASE, ignoreHTTPSErrors: true,
+ extraHTTPHeaders: { Authorization: `Bearer ${sess.accessToken}` },
+ })
+ type Paged = { items: T[] }
+ const units = await (await ctx.get('/api/catalog/units-of-measure?pageSize=200')).json() as Paged<{ id: string; code: string }>
+ const groups = await (await ctx.get('/api/catalog/product-groups')).json() as Paged<{ id: string }>
+ const pts = await (await ctx.get('/api/catalog/price-types')).json() as Paged<{ id: string; isRetail: boolean }>
+ const curs = await (await ctx.get('/api/catalog/currencies')).json() as Paged<{ id: string; code: string }>
+ const r = await ctx.post('/api/catalog/products', {
+ data: {
+ name: 'PhotoTest', article: `PHOTO-${Date.now()}`,
+ unitOfMeasureId: units.items.find(u => u.code === '796')!.id, vat: 12, vatEnabled: true,
+ productGroupId: groups.items[0].id, packaging: 1,
+ prices: [{ priceTypeId: pts.items.find(p => p.isRetail)!.id, amount: 100, currencyId: curs.items.find(c => c.code === 'KZT')!.id }],
+ barcodes: [{ code: '5000000000010', type: 1, isPrimary: true }],
+ },
+ })
+ expect([200, 201]).toContain(r.status())
+ const created = await r.json() as { id: string }
+ await ctx.dispose()
+
+ // Генерируем 1x1 PNG (минимальный валидный)
+ const pngPath = path.join('/tmp', `test-img-${Date.now()}.png`)
+ const oneByOnePng = Buffer.from(
+ 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNgAAIAAAUAAen63NgAAAAASUVORK5CYII=',
+ 'base64',
+ )
+ await fs.writeFile(pngPath, oneByOnePng)
+
+ await attachSession(page, sess, `/catalog/products/${created.id}`)
+ await page.waitForLoadState('networkidle')
+
+ // Скролл до секции «Изображения»
+ await page.getByText('Изображения').first().scrollIntoViewIfNeeded()
+ const fileInput = page.locator('input[type="file"]').first()
+ await expect(fileInput).toBeAttached({ timeout: 5_000 })
+
+ // Слушаем upload response чтобы понять, что произошло
+ const uploadResp = page.waitForResponse(
+ (r) => r.url().includes(`/api/catalog/products/${created.id}/images`) && r.request().method() === 'POST',
+ { timeout: 15_000 },
+ )
+ await fileInput.setInputFiles(pngPath)
+ const resp = await uploadResp
+ const status = resp.status()
+ // 200/201 — success; 415 mime — баг; 413 — слишком большой; 400 — что-то ещё
+ expect(status, `image upload response (${await resp.text().catch(() => '')})`).toBeLessThan(400)
+
+ expectNoErrors(errs, 'upload image')
+ await fs.unlink(pngPath).catch(() => {})
+ })
+})