()
+ return { ...actual, useNavigate: () => mockNavigate }
+})
+
+vi.mock('sonner', () => ({
+ toast: { success: vi.fn(), error: vi.fn() },
+}))
+
+const sampleMedio = {
+ id: 5,
+ codigo: 'WEB01',
+ nombre: 'Portal Web',
+ tipo: 3,
+ plataformaEmpresaId: 42,
+ activo: true,
+ fechaCreacion: '2026-01-01T00:00:00Z',
+ fechaModificacion: null,
+}
+
+const server = setupServer()
+
+beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
+afterEach(() => {
+ server.resetHandlers()
+ vi.clearAllMocks()
+})
+afterAll(() => server.close())
+
+function renderPage(id = '5') {
+ const qc = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ })
+ return render(
+
+
+
+ } />
+
+
+ ,
+ )
+}
+
+describe('EditMedioPage', () => {
+ it('shows loading state initially', () => {
+ server.use(
+ http.get(`${API_URL}/api/v1/admin/medios/5`, () =>
+ HttpResponse.json(sampleMedio),
+ ),
+ )
+
+ renderPage()
+ expect(screen.getByText(/cargando/i)).toBeInTheDocument()
+ })
+
+ it('loads and pre-fills form with medio data', async () => {
+ server.use(
+ http.get(`${API_URL}/api/v1/admin/medios/5`, () =>
+ HttpResponse.json(sampleMedio),
+ ),
+ )
+
+ renderPage()
+
+ await waitFor(() =>
+ expect((screen.getByLabelText(/nombre/i) as HTMLInputElement).value).toBe('Portal Web'),
+ )
+ expect((screen.getByLabelText(/cΓ³digo/i) as HTMLInputElement).value).toBe('WEB01')
+ // CΓ³digo disabled in edit mode
+ expect((screen.getByLabelText(/cΓ³digo/i) as HTMLInputElement).disabled).toBe(true)
+ })
+
+ it('shows "Medio no encontrado" when 404', async () => {
+ server.use(
+ http.get(`${API_URL}/api/v1/admin/medios/999`, () =>
+ new HttpResponse(null, { status: 404 }),
+ ),
+ )
+
+ renderPage('999')
+
+ await waitFor(() =>
+ expect(screen.getByText(/medio no encontrado/i)).toBeInTheDocument(),
+ )
+ })
+
+ it('shows "Guardar cambios" button in edit mode', async () => {
+ server.use(
+ http.get(`${API_URL}/api/v1/admin/medios/5`, () =>
+ HttpResponse.json(sampleMedio),
+ ),
+ )
+
+ renderPage()
+
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: /guardar cambios/i })).toBeInTheDocument(),
+ )
+ })
+})
diff --git a/src/web/src/tests/features/medios/MedioForm.test.tsx b/src/web/src/tests/features/medios/MedioForm.test.tsx
new file mode 100644
index 0000000..021ee0d
--- /dev/null
+++ b/src/web/src/tests/features/medios/MedioForm.test.tsx
@@ -0,0 +1,114 @@
+import { describe, it, expect, beforeAll, afterAll, afterEach, 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 { MedioForm } from '../../../features/medios/components/MedioForm'
+import type { MedioFormValues } from '../../../features/medios/components/MedioForm'
+import type { MedioDetail } from '../../../features/medios/types'
+
+vi.mock('sonner', () => ({
+ toast: { success: vi.fn(), error: vi.fn() },
+}))
+
+const sampleMedio: MedioDetail = {
+ id: 1,
+ codigo: 'DIA01',
+ nombre: 'Diario Principal',
+ tipo: 1,
+ plataformaEmpresaId: null,
+ activo: true,
+ fechaCreacion: '2026-01-01T00:00:00Z',
+ fechaModificacion: null,
+}
+
+function renderForm(opts: { initialData?: MedioDetail; onSubmit?: (v: MedioFormValues) => void } = {}) {
+ const qc = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ })
+ const onSubmit = opts.onSubmit ?? vi.fn()
+ render(
+
+
+
+
+ ,
+ )
+ return { onSubmit }
+}
+
+describe('MedioForm β create mode', () => {
+ it('shows validation error when cΓ³digo is empty', async () => {
+ renderForm()
+ await userEvent.click(screen.getByRole('button', { name: /crear medio/i }))
+ await waitFor(() =>
+ expect(screen.getByText(/cΓ³digo es requerido/i)).toBeInTheDocument(),
+ )
+ })
+
+ it('shows validation error when nombre is empty', async () => {
+ renderForm()
+ await userEvent.type(screen.getByLabelText(/cΓ³digo/i), 'COD1')
+ await userEvent.click(screen.getByRole('button', { name: /crear medio/i }))
+ await waitFor(() =>
+ expect(screen.getByText(/nombre es requerido/i)).toBeInTheDocument(),
+ )
+ })
+
+ it('shows validation error when tipo is not selected', async () => {
+ renderForm()
+ await userEvent.type(screen.getByLabelText(/cΓ³digo/i), 'COD1')
+ await userEvent.type(screen.getByLabelText(/nombre/i), 'Mi Medio')
+ await userEvent.click(screen.getByRole('button', { name: /crear medio/i }))
+ // The form message for tipo validation appears as a role
+ await waitFor(() => {
+ const messages = screen.getAllByText(/seleccionΓ‘ un tipo/i)
+ // At least one message should exist (validation error, not just the placeholder option)
+ expect(messages.length).toBeGreaterThanOrEqual(1)
+ })
+ })
+
+ it('calls onSubmit with correct values on valid form', async () => {
+ const onSubmit = vi.fn()
+ renderForm({ onSubmit })
+
+ await userEvent.type(screen.getByLabelText(/cΓ³digo/i), 'DIA99')
+ await userEvent.type(screen.getByLabelText(/nombre/i), 'Mi Diario')
+
+ // Open the Radix Select trigger and pick option
+ await userEvent.click(screen.getByRole('combobox', { name: /tipo/i }))
+ await userEvent.click(screen.getByRole('option', { name: /diario/i }))
+
+ await userEvent.click(screen.getByRole('button', { name: /crear medio/i }))
+
+ await waitFor(() => {
+ expect(onSubmit).toHaveBeenCalled()
+ const firstArg = onSubmit.mock.calls[0][0]
+ expect(firstArg).toMatchObject({ codigo: 'DIA99', nombre: 'Mi Diario', tipo: 1 })
+ })
+ })
+})
+
+describe('MedioForm β edit mode', () => {
+ it('cΓ³digo field is disabled in edit mode', () => {
+ renderForm({ initialData: sampleMedio })
+ const codigoInput = screen.getByLabelText(/cΓ³digo/i) as HTMLInputElement
+ expect(codigoInput.disabled).toBe(true)
+ })
+
+ it('pre-fills form with initialData values', () => {
+ renderForm({ initialData: sampleMedio })
+ expect((screen.getByLabelText(/cΓ³digo/i) as HTMLInputElement).value).toBe('DIA01')
+ expect((screen.getByLabelText(/nombre/i) as HTMLInputElement).value).toBe('Diario Principal')
+ })
+
+ it('shows "Guardar cambios" button in edit mode', () => {
+ renderForm({ initialData: sampleMedio })
+ expect(screen.getByRole('button', { name: /guardar cambios/i })).toBeInTheDocument()
+ })
+})
diff --git a/src/web/src/tests/features/medios/MediosListPage.test.tsx b/src/web/src/tests/features/medios/MediosListPage.test.tsx
new file mode 100644
index 0000000..34470ce
--- /dev/null
+++ b/src/web/src/tests/features/medios/MediosListPage.test.tsx
@@ -0,0 +1,169 @@
+import { describe, it, expect, beforeAll, afterAll, afterEach, vi } 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, Routes, Route } from 'react-router-dom'
+import { MediosListPage } from '../../../features/medios/pages/MediosListPage'
+import { useAuthStore } from '../../../stores/authStore'
+
+const API_URL = 'http://localhost:5000'
+
+vi.mock('sonner', () => ({
+ toast: { success: vi.fn(), error: vi.fn() },
+}))
+
+const mockNavigate = vi.fn()
+vi.mock('react-router-dom', async (importOriginal) => {
+ const actual = await importOriginal()
+ return { ...actual, useNavigate: () => mockNavigate }
+})
+
+const adminUserWithMedios = {
+ id: 1,
+ username: 'admin',
+ nombre: 'Admin',
+ rol: 'admin',
+ permisos: ['administracion:medios:gestionar'],
+ mustChangePassword: false,
+}
+
+const adminUserWithoutMedios = {
+ id: 2,
+ username: 'cajero',
+ nombre: 'Cajero',
+ rol: 'cajero',
+ permisos: [],
+ mustChangePassword: false,
+}
+
+function makeMedios(n: number) {
+ return Array.from({ length: n }, (_, i) => ({
+ id: i + 1,
+ codigo: `MEDIO${i + 1}`,
+ nombre: `Medio ${i + 1}`,
+ tipo: (i % 4) + 1,
+ plataformaEmpresaId: null,
+ activo: true,
+ }))
+}
+
+const server = setupServer()
+
+beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
+afterEach(() => {
+ server.resetHandlers()
+ useAuthStore.getState().clearAuth()
+ vi.clearAllMocks()
+})
+afterAll(() => server.close())
+
+function renderPage(user = adminUserWithMedios) {
+ useAuthStore.setState({ user })
+ const qc = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ })
+ return render(
+
+
+
+ } />
+
+
+ ,
+ )
+}
+
+describe('MediosListPage', () => {
+ it('renders seed rows when API returns items', async () => {
+ server.use(
+ http.get(`${API_URL}/api/v1/admin/medios`, () =>
+ HttpResponse.json({ items: makeMedios(3), page: 1, pageSize: 20, total: 3 }),
+ ),
+ )
+
+ renderPage()
+
+ await waitFor(() => expect(screen.getByText('MEDIO1')).toBeInTheDocument())
+ expect(screen.getByText('MEDIO2')).toBeInTheDocument()
+ expect(screen.getByText('MEDIO3')).toBeInTheDocument()
+ })
+
+ it('shows empty state when items is empty', async () => {
+ server.use(
+ http.get(`${API_URL}/api/v1/admin/medios`, () =>
+ HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 }),
+ ),
+ )
+
+ renderPage()
+
+ await waitFor(() =>
+ expect(screen.getByText(/sin resultados/i)).toBeInTheDocument(),
+ )
+ })
+
+ it('hides "Nuevo medio" button when user lacks permission', async () => {
+ server.use(
+ http.get(`${API_URL}/api/v1/admin/medios`, () =>
+ HttpResponse.json({ items: makeMedios(2), page: 1, pageSize: 20, total: 2 }),
+ ),
+ )
+
+ renderPage(adminUserWithoutMedios)
+
+ // Wait for page to render
+ await waitFor(() => expect(screen.queryByRole('button', { name: /nuevo medio/i })).not.toBeInTheDocument())
+ })
+
+ it('shows "Nuevo medio" button when user has permission', async () => {
+ server.use(
+ http.get(`${API_URL}/api/v1/admin/medios`, () =>
+ HttpResponse.json({ items: makeMedios(2), page: 1, pageSize: 20, total: 2 }),
+ ),
+ )
+
+ renderPage()
+
+ await waitFor(() =>
+ expect(screen.getByRole('button', { name: /nuevo medio/i })).toBeInTheDocument(),
+ )
+ })
+
+ it('filter by tipo adds querystring tipo', async () => {
+ const requests: string[] = []
+ server.use(
+ http.get(`${API_URL}/api/v1/admin/medios`, ({ request }) => {
+ requests.push(request.url)
+ return HttpResponse.json({ items: [], page: 1, pageSize: 20, total: 0 })
+ }),
+ )
+
+ renderPage()
+
+ await waitFor(() => expect(requests.length).toBeGreaterThan(0))
+
+ // Open the Radix Select trigger and pick "Diario" (value=1)
+ await userEvent.click(screen.getByRole('combobox', { name: /tipo/i }))
+ await userEvent.click(screen.getByRole('option', { name: /^diario$/i }))
+
+ await waitFor(() => {
+ const filtered = requests.find((u) => u.includes('tipo=1'))
+ expect(filtered).toBeTruthy()
+ })
+ })
+
+ it('prev button disabled on first page', async () => {
+ server.use(
+ http.get(`${API_URL}/api/v1/admin/medios`, () =>
+ HttpResponse.json({ items: makeMedios(3), page: 1, pageSize: 20, total: 3 }),
+ ),
+ )
+
+ renderPage()
+
+ await waitFor(() => expect(screen.getByText('MEDIO1')).toBeInTheDocument())
+ expect(screen.getByRole('button', { name: /anterior/i })).toBeDisabled()
+ })
+})
diff --git a/src/web/src/tests/features/secciones/DeactivateSeccionModal.test.tsx b/src/web/src/tests/features/secciones/DeactivateSeccionModal.test.tsx
new file mode 100644
index 0000000..310e087
--- /dev/null
+++ b/src/web/src/tests/features/secciones/DeactivateSeccionModal.test.tsx
@@ -0,0 +1,74 @@
+import { describe, it, expect, beforeAll, afterAll, afterEach, vi } 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 { DeactivateSeccionModal } from '../../../features/secciones/components/DeactivateSeccionModal'
+
+const API_URL = 'http://localhost:5000'
+
+vi.mock('sonner', () => ({
+ toast: { success: vi.fn(), error: vi.fn() },
+}))
+
+const server = setupServer()
+
+beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
+afterEach(() => {
+ server.resetHandlers()
+ vi.clearAllMocks()
+})
+afterAll(() => server.close())
+
+function renderModal(activo = true) {
+ const qc = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ })
+ return render(
+
+
+
+
+ ,
+ )
+}
+
+describe('DeactivateSeccionModal', () => {
+ it('shows "Desactivar" trigger when secciΓ³n is active', () => {
+ renderModal(true)
+ expect(screen.getByRole('button', { name: /desactivar/i })).toBeInTheDocument()
+ })
+
+ it('shows "Reactivar" trigger when secciΓ³n is inactive', () => {
+ renderModal(false)
+ expect(screen.getByRole('button', { name: /reactivar/i })).toBeInTheDocument()
+ })
+
+ it('opens dialog and shows confirmation text', async () => {
+ renderModal(true)
+ await userEvent.click(screen.getByRole('button', { name: /desactivar/i }))
+ await waitFor(() =>
+ expect(screen.getByText(/desactivar secciΓ³n/i)).toBeInTheDocument(),
+ )
+ expect(screen.getByText(/clasificados autos/i)).toBeInTheDocument()
+ })
+
+ it('calls deactivate endpoint on confirm', async () => {
+ let called = false
+ server.use(
+ http.post(`${API_URL}/api/v1/admin/secciones/1/deactivate`, () => {
+ called = true
+ return new HttpResponse(null, { status: 204 })
+ }),
+ )
+
+ renderModal(true)
+ await userEvent.click(screen.getByRole('button', { name: /desactivar/i }))
+ await waitFor(() => screen.getByRole('alertdialog'))
+ await userEvent.click(screen.getByRole('button', { name: /desactivar$/i }))
+
+ await waitFor(() => expect(called).toBe(true))
+ })
+})
diff --git a/src/web/src/tests/features/secciones/SeccionForm.test.tsx b/src/web/src/tests/features/secciones/SeccionForm.test.tsx
new file mode 100644
index 0000000..741f4bd
--- /dev/null
+++ b/src/web/src/tests/features/secciones/SeccionForm.test.tsx
@@ -0,0 +1,147 @@
+import { describe, it, expect, beforeAll, afterAll, afterEach, vi } 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 { SeccionForm } from '../../../features/secciones/components/SeccionForm'
+import type { SeccionFormValues } from '../../../features/secciones/components/SeccionForm'
+import type { SeccionDetail } from '../../../features/secciones/types'
+
+const API_URL = 'http://localhost:5000'
+
+vi.mock('sonner', () => ({
+ toast: { success: vi.fn(), error: vi.fn() },
+}))
+
+const mockMedios = [
+ { id: 1, codigo: 'DIA01', nombre: 'Diario El DΓa', tipo: 1, plataformaEmpresaId: null, activo: true },
+ { id: 2, codigo: 'RAD01', nombre: 'Radio AM', tipo: 2, plataformaEmpresaId: null, activo: true },
+]
+
+const sampleSeccion: SeccionDetail = {
+ id: 1,
+ medioId: 1,
+ codigo: 'CLAS01',
+ nombre: 'Clasificados Autos',
+ tipo: 'clasificados',
+ activo: true,
+ fechaCreacion: '2026-01-01T00:00:00Z',
+ fechaModificacion: null,
+}
+
+const server = setupServer()
+
+beforeAll(() => server.listen({ onUnhandledRequest: 'error' }))
+afterEach(() => {
+ server.resetHandlers()
+ vi.clearAllMocks()
+})
+afterAll(() => server.close())
+
+function renderForm(opts: { initialData?: SeccionDetail; onSubmit?: (v: SeccionFormValues) => void } = {}) {
+ const qc = new QueryClient({
+ defaultOptions: { queries: { retry: false }, mutations: { retry: false } },
+ })
+ const onSubmit = opts.onSubmit ?? vi.fn()
+ server.use(
+ http.get(`${API_URL}/api/v1/admin/medios`, () =>
+ HttpResponse.json({ items: mockMedios, page: 1, pageSize: 200, total: 2 }),
+ ),
+ )
+ render(
+
+
+
+
+ ,
+ )
+ return { onSubmit }
+}
+
+describe('SeccionForm β create mode', () => {
+ it('shows validation error when cΓ³digo is empty', async () => {
+ renderForm()
+ await userEvent.click(screen.getByRole('button', { name: /crear secciΓ³n/i }))
+ await waitFor(() =>
+ expect(screen.getByText(/cΓ³digo es requerido/i)).toBeInTheDocument(),
+ )
+ })
+
+ it('shows validation error when tipo is not selected', async () => {
+ renderForm()
+ await userEvent.type(screen.getByLabelText(/cΓ³digo/i), 'CLAS99')
+ await userEvent.type(screen.getByLabelText(/nombre/i), 'Mi SecciΓ³n')
+ await userEvent.click(screen.getByRole('button', { name: /crear secciΓ³n/i }))
+ await waitFor(() =>
+ expect(screen.getByText(/seleccionΓ‘ un tipo/i)).toBeInTheDocument(),
+ )
+ })
+
+ it('calls onSubmit with correct values on valid form', async () => {
+ const onSubmit = vi.fn()
+ renderForm({ onSubmit })
+
+ // Open Medio trigger, wait for medios to load, then pick one
+ const medioTrigger = screen.getByRole('combobox', { name: /medio/i })
+ await userEvent.click(medioTrigger)
+ await waitFor(() =>
+ expect(screen.getByRole('option', { name: 'Diario El DΓa' })).toBeInTheDocument(),
+ )
+ await userEvent.click(screen.getByRole('option', { name: 'Diario El DΓa' }))
+
+ await userEvent.type(screen.getByLabelText(/cΓ³digo/i), 'CLAS99')
+ await userEvent.type(screen.getByLabelText(/nombre/i), 'Mi SecciΓ³n')
+
+ // Open Tipo trigger and pick Clasificados
+ const tipoTrigger = screen.getByRole('combobox', { name: /tipo de secciΓ³n/i })
+ await userEvent.click(tipoTrigger)
+ await waitFor(() =>
+ expect(screen.getByRole('option', { name: 'Clasificados' })).toBeInTheDocument(),
+ )
+ await userEvent.click(screen.getByRole('option', { name: 'Clasificados' }))
+
+ await userEvent.click(screen.getByRole('button', { name: /crear secciΓ³n/i }))
+
+ await waitFor(() => {
+ expect(onSubmit).toHaveBeenCalled()
+ const firstArg = onSubmit.mock.calls[0][0]
+ expect(firstArg).toMatchObject({
+ medioId: 1,
+ codigo: 'CLAS99',
+ nombre: 'Mi SecciΓ³n',
+ tipo: 'clasificados',
+ })
+ })
+ })
+})
+
+describe('SeccionForm β edit mode', () => {
+ it('cΓ³digo and medioId are disabled in edit mode', async () => {
+ renderForm({ initialData: sampleSeccion })
+ const codigoInput = screen.getByLabelText(/cΓ³digo/i) as HTMLInputElement
+ expect(codigoInput.disabled).toBe(true)
+ // Radix Select trigger is a