From ef3cc25dd4d752253b1d895236289077b1b4ced8 Mon Sep 17 00:00:00 2001 From: Kirill Date: Thu, 28 May 2026 22:33:36 +0500 Subject: [PATCH] asdasd --- client/src/app/layout/MainLayout.tsx | 2 +- .../app/layout/__tests__/MainLayout.test.tsx | 36 +++++++ client/src/features/product-form/index.ts | 2 +- .../use-product-form-helpers.test.ts | 24 +++++ .../lib/use-product-form-helpers.ts | 16 +++ .../product-form/ui/ProductFormFields.tsx | 29 +++--- .../admin-products/ui/AdminProductsPage.tsx | 26 ++--- .../ui/__tests__/AdminProductsPage.test.tsx | 77 ++++++++++++++ server/prisma/prisma/dev.db | Bin 364544 -> 364544 bytes .../api/__tests__/admin-products.test.js | 96 ++++++++++++++++++ server/src/routes/api/admin-products.js | 19 +++- 11 files changed, 297 insertions(+), 30 deletions(-) create mode 100644 client/src/app/layout/__tests__/MainLayout.test.tsx create mode 100644 client/src/features/product-form/lib/__tests__/use-product-form-helpers.test.ts create mode 100644 client/src/pages/admin-products/ui/__tests__/AdminProductsPage.test.tsx create mode 100644 server/src/routes/api/__tests__/admin-products.test.js diff --git a/client/src/app/layout/MainLayout.tsx b/client/src/app/layout/MainLayout.tsx index 3faa34b..063307b 100644 --- a/client/src/app/layout/MainLayout.tsx +++ b/client/src/app/layout/MainLayout.tsx @@ -19,7 +19,7 @@ export function MainLayout({ children }: PropsWithChildren) { const year = new Date().getFullYear() return ( - + diff --git a/client/src/app/layout/__tests__/MainLayout.test.tsx b/client/src/app/layout/__tests__/MainLayout.test.tsx new file mode 100644 index 0000000..2ede55f --- /dev/null +++ b/client/src/app/layout/__tests__/MainLayout.test.tsx @@ -0,0 +1,36 @@ +import { render } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { describe, expect, it, vi } from 'vitest' +import { MainLayout } from '../MainLayout' + +vi.mock('@/app/layout/AppHeader', () => ({ + AppHeader: () =>
Шапка
, +})) + +vi.mock('@/shared/ui/CookieConsentBanner', () => ({ + CookieConsentBanner: () => null, +})) + +vi.mock('@/shared/ui/DemoBanner', () => ({ + DemoBanner: () => null, +})) + +vi.mock('@/shared/ui/ScrollOnNavigate', () => ({ + ScrollOnNavigate: () => null, +})) + +vi.mock('@/shared/ui/ScrollToTop', () => ({ + ScrollToTop: () => null, +})) + +describe('MainLayout', () => { + it('не задает фиксированную минимальную ширину, которая ломает мобильный экран', () => { + const { container } = render( + + Контент + , + ) + + expect(container.firstElementChild).not.toHaveStyle({ minWidth: '500px' }) + }) +}) diff --git a/client/src/features/product-form/index.ts b/client/src/features/product-form/index.ts index 0a0994d..3babc45 100644 --- a/client/src/features/product-form/index.ts +++ b/client/src/features/product-form/index.ts @@ -1,2 +1,2 @@ export type { FormState } from './model/types' -export { emptyForm } from './lib/use-product-form-helpers' +export { emptyForm, isValidProductPriceRub, isValidProductQuantity } from './lib/use-product-form-helpers' diff --git a/client/src/features/product-form/lib/__tests__/use-product-form-helpers.test.ts b/client/src/features/product-form/lib/__tests__/use-product-form-helpers.test.ts new file mode 100644 index 0000000..c783620 --- /dev/null +++ b/client/src/features/product-form/lib/__tests__/use-product-form-helpers.test.ts @@ -0,0 +1,24 @@ +import { describe, expect, it } from 'vitest' +import { isValidProductPriceRub, isValidProductQuantity } from '../use-product-form-helpers' + +describe('product form helpers', () => { + it('принимает корректную цену в рублях', () => { + expect(isValidProductPriceRub('1200')).toBe(true) + expect(isValidProductPriceRub('1200,50')).toBe(true) + }) + + it('отклоняет пустую или некорректную цену', () => { + expect(isValidProductPriceRub('')).toBe(false) + expect(isValidProductPriceRub('0')).toBe(false) + expect(isValidProductPriceRub('1200,555')).toBe(false) + expect(isValidProductPriceRub('1,2,3')).toBe(false) + }) + + it('принимает только целое количество от 0 до 10', () => { + expect(isValidProductQuantity('0')).toBe(true) + expect(isValidProductQuantity('10')).toBe(true) + expect(isValidProductQuantity('')).toBe(false) + expect(isValidProductQuantity('11')).toBe(false) + expect(isValidProductQuantity('1.5')).toBe(false) + }) +}) diff --git a/client/src/features/product-form/lib/use-product-form-helpers.ts b/client/src/features/product-form/lib/use-product-form-helpers.ts index 75e4cbb..3ccfe1c 100644 --- a/client/src/features/product-form/lib/use-product-form-helpers.ts +++ b/client/src/features/product-form/lib/use-product-form-helpers.ts @@ -12,3 +12,19 @@ export const emptyForm = (): FormState => ({ published: true, categoryId: '', }) + +export function isValidProductPriceRub(value: string): boolean { + const trimmed = value.trim() + if (!/^\d+([,.]\d{1,2})?$/.test(trimmed)) return false + + const priceRub = Number(trimmed.replace(',', '.')) + return Number.isFinite(priceRub) && priceRub > 0 && priceRub <= 10_000 +} + +export function isValidProductQuantity(value: string): boolean { + const trimmed = value.trim() + if (!trimmed) return false + + const quantity = Number(trimmed) + return Number.isInteger(quantity) && quantity >= 0 && quantity <= 10 +} diff --git a/client/src/features/product-form/ui/ProductFormFields.tsx b/client/src/features/product-form/ui/ProductFormFields.tsx index b4b8b3a..ecb22d3 100644 --- a/client/src/features/product-form/ui/ProductFormFields.tsx +++ b/client/src/features/product-form/ui/ProductFormFields.tsx @@ -13,6 +13,7 @@ import Typography from '@mui/material/Typography' import { Controller, type UseFormReturn } from 'react-hook-form' import type { Category } from '@/entities/product/model/types' import { OptimizedImage } from '@/shared/ui/OptimizedImage' +import { isValidProductPriceRub, isValidProductQuantity } from '../lib/use-product-form-helpers' import type { FormState } from '../model/types' export function ProductFormFields({ @@ -31,7 +32,17 @@ export function ProductFormFields({ } + rules={{ required: 'Укажите название' }} + render={({ field, fieldState }) => ( + + )} /> { - const n = Number(v) - if (!Number.isInteger(n) || n < 0 || n > 10) return 'Целое число от 0 до 10' - return true - }, + required: 'Укажите количество', + validate: (v) => isValidProductQuantity(v) || 'Целое число от 0 до 10', }} render={({ field, fieldState }) => ( { - const n = Number(v.replace(',', '.')) - if (!Number.isFinite(n) || n <= 0) return 'Цена должна быть больше 0' - if (n > 10_000) return 'Цена не может превышать 10 000 ₽' - if (!Number.isInteger(Math.round(n * 100))) return 'Не более 2 знаков после запятой' - return true - }, + validate: (v) => isValidProductPriceRub(v) || 'Цена должна быть от 0,01 до 10 000 ₽, максимум 2 знака', }} render={({ field, fieldState }) => ( ( Категория diff --git a/client/src/pages/admin-products/ui/AdminProductsPage.tsx b/client/src/pages/admin-products/ui/AdminProductsPage.tsx index 46c3830..1c596d2 100644 --- a/client/src/pages/admin-products/ui/AdminProductsPage.tsx +++ b/client/src/pages/admin-products/ui/AdminProductsPage.tsx @@ -14,7 +14,7 @@ import TableHead from '@mui/material/TableHead' import TableRow from '@mui/material/TableRow' import Typography from '@mui/material/Typography' import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query' -import { useForm } from 'react-hook-form' +import { useForm, useWatch } from 'react-hook-form' import { createProduct, deleteProduct, @@ -23,7 +23,7 @@ import { } from '@/entities/product/api/admin-product-api' import { fetchCategories } from '@/entities/product/api/product-api' import type { Product } from '@/entities/product/model/types' -import { emptyForm, type FormState } from '@/features/product-form' +import { emptyForm, isValidProductPriceRub, isValidProductQuantity, type FormState } from '@/features/product-form' import { GalleryImagePicker } from '@/features/product-form/ui/GalleryImagePicker' import { ProductFormFields } from '@/features/product-form/ui/ProductFormFields' import { formatPriceRub } from '@/shared/lib/format-price' @@ -42,7 +42,15 @@ export function AdminProductsPage() { mode: 'onChange', }) - const titleValue = productForm.watch('title') + const [titleValue, categoryIdValue, quantityValue, priceRubValue, imageUrlsValue] = useWatch({ + control: productForm.control, + name: ['title', 'categoryId', 'quantity', 'priceRub', 'imageUrls'], + }) + const canSubmit = + titleValue.trim().length > 0 && + categoryIdValue.trim().length > 0 && + isValidProductQuantity(quantityValue) && + isValidProductPriceRub(priceRubValue) const categoriesQuery = useQuery({ queryKey: ['categories'], @@ -246,15 +254,7 @@ export function AdminProductsPage() { @@ -265,7 +265,7 @@ export function AdminProductsPage() { open={galleryPickOpen} onClose={() => setGalleryPickOpen(false)} onSelect={handleGallerySelect} - currentUrls={productForm.watch('imageUrls')} + currentUrls={imageUrlsValue} />
) diff --git a/client/src/pages/admin-products/ui/__tests__/AdminProductsPage.test.tsx b/client/src/pages/admin-products/ui/__tests__/AdminProductsPage.test.tsx new file mode 100644 index 0000000..e43ea32 --- /dev/null +++ b/client/src/pages/admin-products/ui/__tests__/AdminProductsPage.test.tsx @@ -0,0 +1,77 @@ +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen, waitFor } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + createProduct, + deleteProduct, + fetchAdminProducts, + updateProduct, +} from '@/entities/product/api/admin-product-api' +import { fetchCategories } from '@/entities/product/api/product-api' +import type { Category } from '@/entities/product/model/types' +import { AdminProductsPage } from '../AdminProductsPage' + +vi.mock('@/entities/product/api/admin-product-api', () => ({ + createProduct: vi.fn(), + deleteProduct: vi.fn(), + fetchAdminProducts: vi.fn(), + updateProduct: vi.fn(), +})) + +vi.mock('@/entities/product/api/product-api', () => ({ + fetchCategories: vi.fn(), +})) + +const category: Category = { + id: 'cat-1', + name: 'Игрушки', + slug: 'igrushki', + sort: 0, +} + +function renderPage() { + const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + + return render( + + + , + ) +} + +describe('AdminProductsPage', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.mocked(fetchCategories).mockResolvedValue([category]) + vi.mocked(fetchAdminProducts).mockResolvedValue([]) + vi.mocked(createProduct).mockResolvedValue({} as never) + vi.mocked(updateProduct).mockResolvedValue({} as never) + vi.mocked(deleteProduct).mockResolvedValue(undefined) + }) + + it('разрешает создать товар после ввода цены и не сбрасывает фокус поля цены', async () => { + const user = userEvent.setup() + renderPage() + + await user.click(screen.getByRole('button', { name: 'Новый товар' })) + await user.type(screen.getByLabelText(/Название/), 'Мишка ручной работы') + await user.click(screen.getByLabelText(/Категория/)) + await user.click(await screen.findByRole('option', { name: 'Игрушки' })) + + const priceInput = screen.getByLabelText(/Цена, ₽/) + await user.click(priceInput) + await user.type(priceInput, '1200') + + expect(priceInput).toHaveFocus() + await waitFor(() => { + expect(screen.getByRole('button', { name: 'Создать' })).not.toBeDisabled() + }) + }) +}) diff --git a/server/prisma/prisma/dev.db b/server/prisma/prisma/dev.db index 86fb7d22ecf87309485bd79bff23e41e17f0f1f1..53c8aac1e511cfe3320b82f37fda59d53bcb2184 100644 GIT binary patch delta 9424 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 TJ*XVL6Q(Ekz>F)#w8#GkHZ`5x delta 4463 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 { + try { + await request.jwtVerify() + } catch { + return reply.code(401).send({ error: 'Unauthorized' }) + } + if (request.user.email !== ADMIN_EMAIL) { + return reply.code(401).send({ error: 'Admin only' }) + } + }) + fastify.decorate('slugify', slugify) + fastify.decorate('parseMaterialsInput', parseMaterialsInput) + fastify.decorate('mapProductForApi', mapProductForApi) + await registerAdminProductRoutes(fastify) + await fastify.ready() + return fastify +} + +function productData(overrides = {}) { + return { + title: 'Медведь', + slug: `bear-${Date.now()}`, + priceCents: 120000, + quantity: 1, + categoryId: category.id, + published: true, + ...overrides, + } +} + +describe('admin product routes', () => { + beforeAll(async () => { + await prisma.product.deleteMany({ where: { category: { slug: { startsWith: 'admin-products-test-' } } } }) + await prisma.category.deleteMany({ where: { slug: { startsWith: 'admin-products-test-' } } }) + await prisma.user.deleteMany({ where: { email: ADMIN_EMAIL } }) + + adminUser = await prisma.user.create({ data: { email: ADMIN_EMAIL } }) + category = await prisma.category.create({ + data: { + name: 'Тестовая категория', + slug: `admin-products-test-${Date.now()}`, + }, + }) + }) + + beforeEach(async () => { + await prisma.product.deleteMany({ where: { categoryId: category.id } }) + app = await buildApp() + }) + + afterEach(async () => { + await app.close() + }) + + afterAll(async () => { + await prisma.product.deleteMany({ where: { categoryId: category.id } }) + await prisma.category.delete({ where: { id: category.id } }) + await prisma.user.delete({ where: { id: adminUser.id } }) + }) + + it('генерирует уникальный slug при создании товара с повторяющимся названием без ручного slug', async () => { + await prisma.product.create({ data: productData({ title: 'Bear', slug: 'bear' }) }) + const token = await signToken(adminUser) + + const res = await app.inject({ + method: 'POST', + url: '/api/admin/products', + headers: { authorization: `Bearer ${token}` }, + payload: productData({ title: 'Bear', slug: undefined }), + }) + + expect(res.statusCode).toBe(201) + expect(res.json().slug).toBe('bear-2') + }) +}) diff --git a/server/src/routes/api/admin-products.js b/server/src/routes/api/admin-products.js index 5d8d1e3..cba3bc0 100644 --- a/server/src/routes/api/admin-products.js +++ b/server/src/routes/api/admin-products.js @@ -40,6 +40,19 @@ const PATCH_PRODUCT_SCHEMA = { }, } +async function buildUniqueProductSlug(baseSlug) { + const base = String(baseSlug || '').trim() + let candidate = base + let suffix = 2 + + while (await prisma.product.findUnique({ where: { slug: candidate } })) { + candidate = `${base}-${suffix}` + suffix += 1 + } + + return candidate +} + export async function registerAdminProductRoutes(fastify) { fastify.get('/api/admin/products', { preHandler: [fastify.verifyAdmin] }, async (request) => { const items = await prisma.product.findMany({ @@ -59,7 +72,9 @@ export async function registerAdminProductRoutes(fastify) { reply.code(400).send({ error: 'Укажите название' }) return } - const slug = String(body.slug ?? '').trim() || request.server.slugify(title) || `item-${Date.now()}` + const requestedSlug = String(body.slug ?? '').trim() + const slugBase = requestedSlug || request.server.slugify(title) || `item-${Date.now()}` + const slug = requestedSlug ? slugBase : await buildUniqueProductSlug(slugBase) const categoryId = String(body.categoryId ?? '').trim() if (!categoryId) { reply.code(400).send({ error: 'Укажите категорию' }) @@ -79,7 +94,7 @@ export async function registerAdminProductRoutes(fastify) { reply.code(400).send({ error: 'Цена не может превышать 10 000 ₽' }) return } - const exists = await prisma.product.findUnique({ where: { slug } }) + const exists = requestedSlug ? await prisma.product.findUnique({ where: { slug } }) : null if (exists) { reply.code(409).send({ error: 'Такой slug уже занят' }) return