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 86fb7d2..53c8aac 100644 Binary files a/server/prisma/prisma/dev.db and b/server/prisma/prisma/dev.db differ diff --git a/server/src/routes/api/__tests__/admin-products.test.js b/server/src/routes/api/__tests__/admin-products.test.js new file mode 100644 index 0000000..2deecbd --- /dev/null +++ b/server/src/routes/api/__tests__/admin-products.test.js @@ -0,0 +1,96 @@ +import jwt from '@fastify/jwt' +import Fastify from 'fastify' +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' +import { prisma } from '../../../lib/prisma.js' +import { mapProductForApi, parseMaterialsInput, slugify } from '../_product-helpers.js' +import { registerAdminProductRoutes } from '../admin-products.js' + +const JWT_SECRET = 'test-secret' +const ADMIN_EMAIL = `admin-products-${Date.now()}@example.com` + +let app +let adminUser +let category + +async function signToken(user) { + return app.jwt.sign({ sub: user.id, email: user.email }) +} + +async function buildApp() { + const fastify = Fastify({ logger: false }) + await fastify.register(jwt, { secret: JWT_SECRET }) + fastify.decorate('verifyAdmin', async (request, reply) => { + 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