Compare commits
No commits in common. "64cc5b0d108c26f46ea653080f8197826d66a61a" and "eb867697d07201678b9d8859f7561ebb487566ed" have entirely different histories.
64cc5b0d10
...
eb867697d0
|
|
@ -184,12 +184,7 @@ export function ProductEditPage() {
|
||||||
const remove = useMutation({
|
const remove = useMutation({
|
||||||
mutationFn: async () => { await api.delete(`/api/catalog/products/${id}`) },
|
mutationFn: async () => { await api.delete(`/api/catalog/products/${id}`) },
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
// Просто navigate — компонент размонтируется, useQuery cleanup'нёт
|
qc.invalidateQueries({ queryKey: ['/api/catalog/products'] })
|
||||||
// подписку, мы НЕ дёргаем invalidate/removeQueries: они бы стриггерили
|
|
||||||
// refetch на уже удалённый id (active subscriber на момент срабатывания)
|
|
||||||
// и Toaster показал бы «Не найдено» поверх редиректа.
|
|
||||||
// На /catalog/products fresh fetch list-query сам подгрузит актуальный
|
|
||||||
// список без удалённой записи.
|
|
||||||
navigate('/catalog/products')
|
navigate('/catalog/products')
|
||||||
},
|
},
|
||||||
meta: { successMessage: 'Удалено' },
|
meta: { successMessage: 'Удалено' },
|
||||||
|
|
|
||||||
|
|
@ -87,10 +87,6 @@ export function watchPage(page: Page, opts?: {
|
||||||
page.on('console', (msg: ConsoleMessage) => {
|
page.on('console', (msg: ConsoleMessage) => {
|
||||||
if (msg.type() !== 'error') return
|
if (msg.type() !== 'error') return
|
||||||
const t = msg.text()
|
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
|
if ((opts?.expectedConsoleContains ?? []).some(s => t.includes(s))) return
|
||||||
acc.console.push(t)
|
acc.console.push(t)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,118 +0,0 @@
|
||||||
/**
|
|
||||||
* 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 })
|
|
||||||
// <body> непустой
|
|
||||||
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()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
@ -1,254 +0,0 @@
|
||||||
/**
|
|
||||||
* 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/<id>
|
|
||||||
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<T> = { 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<T> = { 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<T> = { 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(() => {})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
Loading…
Reference in a new issue