diff --git a/src/web/src/tests/features/products/DeactivateProductDialog.test.tsx b/src/web/src/tests/features/products/DeactivateProductDialog.test.tsx
new file mode 100644
index 0000000..e9a1810
--- /dev/null
+++ b/src/web/src/tests/features/products/DeactivateProductDialog.test.tsx
@@ -0,0 +1,99 @@
+import { describe, it, expect, vi } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { MemoryRouter } from 'react-router-dom'
+import React from 'react'
+import { DeactivateProductDialog } from '../../../features/products/components/DeactivateProductDialog'
+import type { ProductListItem } from '../../../features/products/types'
+
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+
+const sampleProduct: ProductListItem = {
+ id: 1,
+ nombre: 'Clasificado Estándar',
+ medioId: 1,
+ productTypeId: 2,
+ rubroId: null,
+ basePrice: 100.5,
+ priceDurationDays: null,
+ isActive: true,
+}
+
+function wrap(children: React.ReactNode) {
+ const qc = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ })
+ return render(
+
+ {children}
+ ,
+ )
+}
+
+describe('DeactivateProductDialog', () => {
+ it('renders confirmation message with product name', () => {
+ wrap(
+ ,
+ )
+ expect(screen.getByText(/Clasificado Estándar/i)).toBeInTheDocument()
+ expect(screen.getByRole('heading', { name: /desactivar producto/i })).toBeInTheDocument()
+ })
+
+ it('calls onConfirm with product id when user confirms', async () => {
+ const onConfirm = vi.fn().mockResolvedValue(undefined)
+ wrap(
+ ,
+ )
+ const buttons = screen.getAllByRole('button', { name: /desactivar/i })
+ const confirmBtn = buttons.find((b) => b.textContent?.trim() === 'Desactivar')!
+ await userEvent.click(confirmBtn)
+ await waitFor(() => expect(onConfirm).toHaveBeenCalledWith(sampleProduct.id))
+ })
+
+ it('shows inline error when backend returns 409', async () => {
+ const onConfirm = vi.fn(() =>
+ Promise.reject({
+ response: { status: 409, data: { message: 'No se puede desactivar: el producto está en uso.' } },
+ }),
+ )
+ wrap(
+ ,
+ )
+ const buttons = screen.getAllByRole('button', { name: /desactivar/i })
+ const confirmBtn = buttons.find((b) => b.textContent?.trim() === 'Desactivar')!
+ await userEvent.click(confirmBtn)
+ await waitFor(() => {
+ expect(screen.getByText(/en uso/i)).toBeInTheDocument()
+ })
+ })
+
+ it('closes dialog when cancel is clicked', async () => {
+ const onOpenChange = vi.fn()
+ wrap(
+ ,
+ )
+ await userEvent.click(screen.getByRole('button', { name: /cancelar/i }))
+ await waitFor(() => expect(onOpenChange).toHaveBeenCalled())
+ })
+})
diff --git a/src/web/src/tests/features/products/ProductFormDialog.test.tsx b/src/web/src/tests/features/products/ProductFormDialog.test.tsx
new file mode 100644
index 0000000..2b79712
--- /dev/null
+++ b/src/web/src/tests/features/products/ProductFormDialog.test.tsx
@@ -0,0 +1,194 @@
+import { describe, it, expect, vi, beforeAll, afterAll, afterEach } from 'vitest'
+import { render, screen, waitFor } from '@testing-library/react'
+import userEvent from '@testing-library/user-event'
+import { http, HttpResponse } from 'msw'
+import { setupServer } from 'msw/node'
+import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
+import { MemoryRouter } from 'react-router-dom'
+import React from 'react'
+import { ProductFormDialog } from '../../../features/products/components/ProductFormDialog'
+import type { ProductDetail } from '../../../features/products/types'
+import type { PagedResult } from '../../../features/product-types/types'
+import type { ProductTypeListItem } from '../../../features/product-types/types'
+
+vi.mock('sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
+
+const API_URL = 'http://localhost:5000'
+
+const ptSimple: ProductTypeListItem = {
+ id: 2,
+ nombre: 'Simple',
+ hasDuration: false,
+ requiresText: false,
+ requiresCategory: false,
+ isBundle: false,
+ allowImages: false,
+ isActive: true,
+}
+
+const mockProductTypesPaged: PagedResult = {
+ items: [ptSimple],
+ page: 1,
+ pageSize: 50,
+ total: 1,
+}
+
+const mockProduct: ProductDetail = {
+ id: 1,
+ nombre: 'Clasificado Estándar',
+ medioId: 1,
+ productTypeId: 2,
+ rubroId: null,
+ basePrice: 100.5,
+ priceDurationDays: null,
+ isActive: true,
+ fechaCreacion: '2026-04-19T00:00:00Z',
+ fechaModificacion: null,
+}
+
+const server = setupServer()
+
+beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
+afterEach(() => {
+ server.resetHandlers()
+ vi.clearAllMocks()
+})
+afterAll(() => server.close())
+
+function wrap(children: React.ReactNode) {
+ const qc = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ })
+ return render(
+
+ {children}
+ ,
+ )
+}
+
+function withProductTypesHandler() {
+ server.use(
+ http.get(`${API_URL}/api/v1/product-types`, () =>
+ HttpResponse.json(mockProductTypesPaged),
+ ),
+ )
+}
+
+// ─── ProductFormDialog — create mode ─────────────────────────────────────
+
+describe('ProductFormDialog — create mode', () => {
+ it('renders create dialog title when no product prop', () => {
+ withProductTypesHandler()
+ wrap()
+ expect(screen.getByRole('heading', { name: /nuevo producto/i })).toBeInTheDocument()
+ })
+
+ it('renders DialogDescription (accessibility)', () => {
+ withProductTypesHandler()
+ wrap()
+ expect(screen.getByText(/completá los datos/i)).toBeInTheDocument()
+ })
+
+ it('calls create mutation and closes dialog on success', async () => {
+ withProductTypesHandler()
+ server.use(
+ http.post(`${API_URL}/api/v1/admin/products`, () =>
+ HttpResponse.json({ id: 10, nombre: 'Nuevo Producto' }, { status: 201 }),
+ ),
+ )
+ const onOpenChange = vi.fn()
+ wrap()
+ await userEvent.type(screen.getByLabelText(/nombre/i), 'Nuevo Producto')
+ await userEvent.type(screen.getByLabelText(/id de medio/i), '1')
+ await userEvent.type(screen.getByLabelText(/id de tipo de producto/i), '2')
+ await userEvent.type(screen.getByLabelText(/precio base/i), '100')
+ await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
+ await waitFor(() => {
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
+ })
+
+ it('shows inline error when backend returns 409', async () => {
+ withProductTypesHandler()
+ server.use(
+ http.post(`${API_URL}/api/v1/admin/products`, () =>
+ HttpResponse.json(
+ { error: 'product_nombre_duplicado', message: 'Ya existe un producto con ese nombre' },
+ { status: 409 },
+ ),
+ ),
+ )
+ wrap()
+ await userEvent.type(screen.getByLabelText(/nombre/i), 'Duplicado')
+ await userEvent.type(screen.getByLabelText(/id de medio/i), '1')
+ await userEvent.type(screen.getByLabelText(/id de tipo de producto/i), '2')
+ await userEvent.type(screen.getByLabelText(/precio base/i), '100')
+ await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
+ await waitFor(() => {
+ expect(screen.getByText(/ya existe un producto con ese nombre/i)).toBeInTheDocument()
+ })
+ })
+
+ it('shows inline error when backend returns 409 ProductTypeInactivo', async () => {
+ withProductTypesHandler()
+ server.use(
+ http.post(`${API_URL}/api/v1/admin/products`, () =>
+ HttpResponse.json(
+ { error: 'product_type_inactivo', message: 'El tipo de producto está inactivo' },
+ { status: 409 },
+ ),
+ ),
+ )
+ wrap()
+ await userEvent.type(screen.getByLabelText(/nombre/i), 'Prod Test')
+ await userEvent.type(screen.getByLabelText(/id de medio/i), '1')
+ await userEvent.type(screen.getByLabelText(/id de tipo de producto/i), '2')
+ await userEvent.type(screen.getByLabelText(/precio base/i), '100')
+ await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
+ await waitFor(() => {
+ expect(screen.getByText(/tipo de producto está inactivo/i)).toBeInTheDocument()
+ })
+ })
+})
+
+// ─── ProductFormDialog — edit mode ────────────────────────────────────────
+
+describe('ProductFormDialog — edit mode', () => {
+ it('renders edit dialog title and pre-fills nombre', () => {
+ withProductTypesHandler()
+ wrap(
+ ,
+ )
+ expect(screen.getByRole('heading', { name: /editar producto/i })).toBeInTheDocument()
+ const input = screen.getByLabelText(/nombre/i) as HTMLInputElement
+ expect(input.value).toBe('Clasificado Estándar')
+ })
+
+ it('calls update mutation and closes dialog on success', async () => {
+ withProductTypesHandler()
+ server.use(
+ http.put(`${API_URL}/api/v1/admin/products/1`, () =>
+ HttpResponse.json({ ...mockProduct, nombre: 'Modificado' }),
+ ),
+ )
+ const onOpenChange = vi.fn()
+ wrap(
+ ,
+ )
+ const input = screen.getByLabelText(/nombre/i) as HTMLInputElement
+ await userEvent.clear(input)
+ await userEvent.type(input, 'Modificado')
+ await userEvent.click(screen.getByRole('button', { name: /guardar/i }))
+ await waitFor(() => {
+ expect(onOpenChange).toHaveBeenCalledWith(false)
+ })
+ })
+})